ember-template-lint
Version:
Linter for Ember or Handlebars templates.
248 lines (225 loc) • 8.73 kB
JavaScript
import AstNodeInfo from '../helpers/ast-node-info.js';
import createErrorMessage from '../helpers/create-error-message.js';
import dasherizeComponentName from '../helpers/dasherize-component-name.js';
import isDasherizedComponentOrHelperName from '../helpers/is-dasherized-component-or-helper-name.js';
import Rule from './_base.js';
const message = 'Tables must have a table group (thead, tbody or tfoot).';
const orderingMessage =
'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).';
const ALLOWED_TABLE_CHILDREN = ['caption', 'colgroup', 'thead', 'tbody', 'tfoot'];
const CONTROL_FLOW_START_MARK = 0;
const CONTROL_FLOW_END_MARK = 1;
// For effective children, we skip over any control flow helpers,
// since we know that they don't render anything on their own
function getEffectiveChildren(node) {
return AstNodeInfo.childrenFor(node).flatMap((childNode) => {
if (AstNodeInfo.isControlFlowHelper(childNode)) {
if (AstNodeInfo.isIf(childNode) || AstNodeInfo.isUnless(childNode)) {
if (childNode.program && childNode.inverse) {
return [
CONTROL_FLOW_START_MARK,
...getEffectiveChildren(childNode.program),
CONTROL_FLOW_END_MARK,
CONTROL_FLOW_START_MARK,
...getEffectiveChildren(childNode.inverse),
CONTROL_FLOW_END_MARK,
];
}
}
return getEffectiveChildren(childNode);
} else {
return [childNode];
}
});
}
function isAllowedTableChild(node, config) {
switch (node.type) {
case 'BlockStatement':
case 'MustacheStatement': {
const tagNamePair = node.hash.pairs.find((pair) => pair.key === 'tagName');
if (tagNamePair) {
const index = ALLOWED_TABLE_CHILDREN.indexOf(tagNamePair.value.value);
return { allowed: index > -1, indices: [index] };
} else if (node.path.original === 'yield') {
return { allowed: true, indices: [] };
} else {
const possibleIndices = config.get(node.path.original) || [];
if (possibleIndices.length > 0) {
return { allowed: true, indices: possibleIndices };
}
}
break;
}
case 'ElementNode': {
let index = ALLOWED_TABLE_CHILDREN.indexOf(node.tag);
if (index > -1) {
return { allowed: true, indices: [index] };
}
const tagNameAttribute = node.attributes.find((attribute) => attribute.name === '@tagName');
if (tagNameAttribute) {
index = ALLOWED_TABLE_CHILDREN.indexOf(tagNameAttribute.value.chars);
return { allowed: index > -1, indices: [index] };
}
const possibleIndices = config.get(dasherizeComponentName(node.tag)) || [];
if (possibleIndices.length > 0) {
return { allowed: true, indices: possibleIndices };
}
break;
}
case 'CommentStatement':
case 'MustacheCommentStatement': {
return { allowed: true, indices: [] };
}
case 'TextNode': {
return { allowed: !/\S/.test(node.chars), indices: [] };
}
}
return { allowed: false };
}
export function createTableGroupsErrorMessage(ruleName, config) {
return createErrorMessage(
ruleName,
[
' One of these:',
' * boolean - `true` to enable / `false` to disable',
' * object[] - with the following keys:',
' * `allowed-table-components` - string[] - components to treat as having the table tag (using kebab-case names like `nested-scope/component-name`)',
' * `allowed-caption-components` - string[] - components to treat as having the caption tag (using kebab-case names like `nested-scope/component-name`)',
' * `allowed-colgroup-components` - string[] - components to treat as having the colgroup tag (using kebab-case names like `nested-scope/component-name`)',
' * `allowed-thead-components` - string[] - components to treat as having the thead tag (using kebab-case names like `nested-scope/component-name`)',
' * `allowed-tbody-components` - string[] - components to treat as having the tbody tag (using kebab-case names like `nested-scope/component-name`)',
' * `allowed-tfoot-components` - string[] - components to treat as having the tfoot tag (using kebab-case names like `nested-scope/component-name`)',
],
config
);
}
export default class TableGroups extends Rule {
parseConfig(config) {
let configType = typeof config;
switch (configType) {
case 'boolean': {
return config
? {
outerTags: new Set(),
internalTags: new Map(),
}
: false;
}
case 'object': {
const allowedComponentKeysByIndex = [
'allowed-caption-components',
'allowed-colgroup-components',
'allowed-thead-components',
'allowed-tbody-components',
'allowed-tfoot-components',
'allowed-table-components',
];
const result = new Map();
let outerTags = new Set();
let isValid = true;
for (const [index, key] of allowedComponentKeysByIndex.entries()) {
if (key in config) {
const allowedComponents = config[key];
if (
Array.isArray(allowedComponents) &&
allowedComponents.every(isDasherizedComponentOrHelperName)
) {
if (key === 'allowed-table-components') {
outerTags = new Set(allowedComponents);
} else {
for (const allowedComponent of allowedComponents) {
if (!result.has(allowedComponent)) {
result.set(allowedComponent, []);
}
result.get(allowedComponent).push(index);
}
}
} else {
isValid = false;
}
}
}
if (isValid && (result.size > 0 || outerTags.size > 0)) {
return {
internalTags: result,
outerTags,
};
} else {
break;
}
}
case 'undefined': {
return false;
}
}
throw new Error(createTableGroupsErrorMessage(this.ruleName, config));
}
/**
* @returns {import('./types.js').VisitorReturnType<TableGroups>}
*/
visitor() {
return {
ElementNode(node) {
if (
node.tag === 'table' ||
this.config.outerTags.has(dasherizeComponentName(node.tag)) ||
node.attributes.some((attr) => {
return (
attr.name === '@tagName' &&
attr.value.type === 'TextNode' &&
attr.value.chars === 'table'
);
})
) {
const children = getEffectiveChildren(node);
let currentAllowedMinimumIndices = new Set([0]);
let scopedIndices = [];
for (const child of children) {
const isControlFlowStartMark = child === CONTROL_FLOW_START_MARK;
const isControlFlowEndMark = child === CONTROL_FLOW_END_MARK;
const isControlMark = isControlFlowStartMark || isControlFlowEndMark;
if (isControlFlowStartMark) {
scopedIndices.push(currentAllowedMinimumIndices);
currentAllowedMinimumIndices = new Set(
scopedIndices.reduce((acc, indices) => {
return [...acc, ...indices];
})
);
continue;
} else if (isControlFlowEndMark) {
currentAllowedMinimumIndices = scopedIndices.pop();
}
if (isControlMark) {
continue;
}
const { allowed, indices } = isAllowedTableChild(child, this.config.internalTags);
if (!allowed) {
this.log({
message,
node,
});
break;
}
// It's possible for a component to be permissible for multiple children, so we need to make sure at least
// one possible tag makes sense.
if (indices.length > 0) {
const newAllowedMinimumIndices = new Set(
[...currentAllowedMinimumIndices].flatMap((currentIndex) => {
return indices.filter((newIndex) => newIndex >= currentIndex);
})
);
if (newAllowedMinimumIndices.size === 0) {
this.log({
message: orderingMessage,
node,
});
break;
}
currentAllowedMinimumIndices = newAllowedMinimumIndices;
}
}
}
},
};
}
}