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

CSS Hierarchy Indent option (off by default) #3038

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,28 @@ Valid options:
| Default | CLI Override | API Override |
| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- |
| `"preserve"` | <code>--prose-wrap <always&#124;never&#124;preserve></code> | <code>proseWrap: "<always&#124;never&#124;preserve>"</code> |

## CSS Hierarchy Indent

_available in v1.9.3+_

This indents adjacent CSS Rules based on their selector's hierarchy. This works well with popular CSS naming conventions such as [BEM](http://getbem.com/) or any convention that uses suffixes to indicate deeper hierarchy.

For example, a file with the following CSS will be indented as:

<!-- prettier-ignore -->
```css
.container {
background: palegoldenrod;
}

.container-row {
background: papayawhip;
}
```

Advocates of CSS Hierarchy Indenting say it soon becomes easier to read because left-most selectors are like section headings, which can help developers organise CSS and identify hierarchy bugs.

| Default | CLI Override | API Override |
| ------- | ------------------------ | ---------------------------- |
| `false` | `--css-hierarchy-indent` | `cssHierarchyIndent: <bool>` |
6 changes: 6 additions & 0 deletions src/cli/constant.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ const detailedOptions = normalizeDetailedOptions({
description:
"Define in which order config files and CLI options should be evaluated."
},
"css-hierarchy-indent": {
type: "boolean",
category: CATEGORY_FORMAT,
forwardToApi: true,
description: "Indent adjacent CSS Rules based on CSS selector hierarchy."
},
"cursor-offset": {
type: "int",
category: CATEGORY_EDITOR,
Expand Down
241 changes: 222 additions & 19 deletions src/language-css/printer-postcss.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,35 +34,54 @@ function genericPrint(path, options, print) {
if (n.raws.content) {
return n.raws.content;
}

const maybeIndentStyle = options.cssHierarchyIndent
? getHierarchyIndent(path, options, print)
: doNotHierarchyIndent;

const text = options.originalText.slice(util.locStart(n), util.locEnd(n));
const rawText = n.raws.text || n.text;
let concatComment;
// Workaround a bug where the location is off.
// https://github.com/postcss/postcss-scss/issues/63
if (text.indexOf(rawText) === -1) {
if (n.raws.inline) {
return concat(["// ", rawText]);
concatComment = ["// ", rawText];
} else {
concatComment = ["/* ", rawText, " */"];
}
return concat(["/* ", rawText, " */"]);
} else {
concatComment = [text];
}
return text;
concatComment.unshift(path.indentStyleAsWhitespace || "");
return maybeIndentStyle(concat(concatComment));
}
case "css-rule": {
return concat([
path.call(print, "selector"),
n.important ? " !important" : "",
n.nodes
? concat([
" {",
n.nodes.length > 0
? indent(
concat([hardline, printNodeSequence(path, options, print)])
)
: "",
hardline,
"}"
])
: ";"
]);
const maybeIndentStyle = options.cssHierarchyIndent
? getHierarchyIndent(path, options, print)
: doNotHierarchyIndent;
return maybeIndentStyle(
concat([
path.indentStyleAsWhitespace || "",
path.call(print, "selector"),
n.important ? " !important" : "",
n.nodes
? concat([
" {",
n.nodes.length > 0
? indent(
concat([
hardline,
printNodeSequence(path, options, print)
])
)
: "",
hardline,
"}"
])
: ";"
])
);
}
case "css-decl": {
// When the following less construct &:extend(.foo); is parsed with scss,
Expand Down Expand Up @@ -533,6 +552,190 @@ function maybeToLowerCase(value) {
: value.toLowerCase();
}

function getHierarchyIndent(path, options, print) {
const n = path.getValue();
const parent = path.getParentNode();
const index = parent.nodes.indexOf(n);
const previous =
index > 0
? findNodeByType(parent.nodes, "css-rule", index - 1, "previous")
: null;

switch (n.type) {
case "css-rule": {
const isNested = !!parent.selector;
if (isNested) {
// Don't indent any nested style
path.indentStyleAsWhitespace = "";
return doNotHierarchyIndent;
}
path.hierarchyIndentTree = getHierarchyIndentTree(
path,
options,
print,
n,
previous
);
path.indentStyleAsWhitespace = " ".repeat(
path.hierarchyIndentTree.length
);
break;
}
case "css-comment": {
// When comments are between indented styles, indent the first line of the comment
// to align with the following selector
const next = findNodeByType(parent.nodes, "css-rule", index, "next");
if (next) {
const nextHierarchyIndentTree = getHierarchyIndentTree(
path,
options,
print,
next,
previous
);
path.indentStyleAsWhitespace = " ".repeat(
nextHierarchyIndentTree.length
);
} else {
// Reset indentation
path.indentStyleAsWhitespace = "";
}
break;
}
}

return path.hierarchyIndentTree.length
? doHierarchyIndent(path.hierarchyIndentTree.length)
: doNotHierarchyIndent;
}

function getHierarchyIndentSelectorPattern(path, options, print, selector) {
// Stringifies selectors for comparing pattern

const SEPARATOR = "\0"; // NUL invalid in CSS so it's a safe separator
const parent = path.getParentNode();

// rebind because path might change
path.pushCall = pushCall.bind(path);

const flatten = (part, nodeType) => {
switch (part.type || nodeType) {
case "concat":
return part.parts.map(flatten).join("");
case "selector-tag":
// Element "section" preceded by element "s" shouldn't be indented,
// so add a character to make Element comparisons atomic.
return `${part}${SEPARATOR}`;
case "selector-combinator":
case "line":
return "";
default:
return part.formatted ? part.formatted : part;
}
};

return selector.nodes
.map(node =>
path.pushCall(
childPath =>
flatten(
genericPrint(childPath, options, print),
path.getValue().type
),
0,
parent,
1,
node
)
)
.join("");
}

function getHierarchyIndentTree(path, options, print, n, previous) {
const tree = path.hierarchyIndentTree || [];
const treeNodes = tree.slice();
if (previous && tree.indexOf(previous) === -1) {
treeNodes.push(previous);
}
let i = treeNodes.length - 1;
const selectorStrings = n.selector.nodes.map(
getHierarchyIndentSelectorPattern.bind(null, path, options, print)
);
while (i >= 0) {
const leaf = treeNodes[i];
if (
leaf &&
leaf.type === "css-rule" &&
leaf.selector.nodes.some(leafSelector => {
const leafSelectorString = getHierarchyIndentSelectorPattern(
path,
options,
print,
leafSelector
);
return selectorStrings.some(
selectorString =>
// To be indented following selectors must be longer...
selectorString.length > leafSelectorString.length &&
// ...and match a substring of leafSelectorString
leafSelectorString ===
selectorString.substr(0, leafSelectorString.length)
);
})
) {
// Found a parent
const newTree = tree.slice(0, i);
newTree.push(leaf);
return newTree;
}
// This Leaf wasn't a parent.
// Try again at a deeper point in the tree
i--;
}
// Found nothing. Reset tree
return [];
}

function findNodeByType(nodes, type, startAt, direction) {
let i = startAt;
while (nodes[i]) {
if (nodes[i] && nodes[i].type === type) {
return nodes[i];
} else if (nodes[i] && nodes[i].type !== "css-comment") {
// Only search past comments, not other node types
return;
}
i = i + (direction === "previous" ? -1 : +1);
}
}

// Similar to FastPath.prototype.call, except that values are pushed onto
// the stack rather than keyed values
// This function is here rather than in fast-path.js (as a prototype) so
// as not to disrupt normal codepaths.
function pushCall(callback /*, value1, value2, ... */) {
const s = this.stack;
const origLen = s.length;
const argc = arguments.length;
for (let i = 1; i < argc; ++i) {
const value = arguments[i];
s.push(value);
}
const result = callback(this);
s.length = origLen;
return result;
}

// Pass through for when CSS is not indented
const doNotHierarchyIndent = arg => arg;

const doHierarchyIndent = times => {
// Returns a function that indents a certain number of times
const repeat = (arg, times) =>
times ? repeat(concat([indent(arg)]), times - 1) : arg;
return arg => repeat(arg, times);
};

module.exports = {
print: genericPrint,
hasPrettierIgnore: util.hasIgnoreComment
Expand Down
1 change: 1 addition & 0 deletions src/main/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const defaults = {
plugins: [],
astFormat: "estree",
printer: {},
cssHierarchyIndent: false,
__inJsTemplate: false
};

Expand Down