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

Feature/more i18n tokens #1504

Merged
3 changes: 3 additions & 0 deletions .eslintplugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
exports.rules = {
i18n: require('./scripts/eslint-plugin-i18n/i18n'),
};
6 changes: 4 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
"@elastic/eslint-config-kibana"
],
"plugins": [
"prettier"
"prettier",
"local"
],
"rules": {
"prefer-template": "error"
"prefer-template": "error",
"local/i18n": "error"
},
"env": {
"jest": true
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## [`master`](https://github.com/elastic/eui/tree/master)

- Changed `flex-basis` value on `EuiPageBody` for better cross-browser support ([#1497](https://github.com/elastic/eui/pull/1497))
- Converted a number of components to support text localization ([#1485](https://github.com/elastic/eui/pull/1485))
- Converted a number of components to support text localization ([#1485](https://github.com/elastic/eui/pull/1485)) ([#1504](https://github.com/elastic/eui/pull/1504))
- Added a seconds option to the refresh interval selection in `EuiSuperDatePicker` ([#1503](https://github.com/elastic/eui/pull/1503))
- Changed to conditionally render `EuiModalBody` if `EuiConfirmModal` has no `children` ([#1500](https://github.com/elastic/eui/pull/1500))

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"eslint-plugin-import": "^2.8.0",
"eslint-plugin-jest": "^21.6.2",
"eslint-plugin-jsx-a11y": "^6.0.2",
"eslint-plugin-local": "^1.0.0",
"eslint-plugin-mocha": "^4.11.0",
"eslint-plugin-prefer-object-spread": "^1.2.1",
"eslint-plugin-prettier": "^2.6.0",
Expand Down
325 changes: 325 additions & 0 deletions scripts/eslint-plugin-i18n/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
// Enforce EuiI18n token names & variable names in render prop

const path = require('path');

function attributesArrayToLookup(attributesArray) {
return attributesArray.reduce(
(lookup, attribute) => {
lookup[attribute.name.name] = attribute.value;
return lookup;
},
{}
);
}

function getDefinedValues(valuesNode) {
if (valuesNode == null) return new Set();
return valuesNode.expression.properties.reduce(
(valueNames, property) => {
valueNames.add(property.key.name);
return valueNames;
},
new Set()
);
}

function formatSet(set) {
return Array.from(set).sort().join(', ');
}

function getExpectedValueNames(defaultString) {
const matches = defaultString.match(/{([a-zA-Z0-9_-]+)}/g);
const expectedNames = new Set();

if (matches) {
matches.forEach(match => {
expectedNames.add(match.substring(1, match.length - 1));
});
}

return expectedNames;
}

function areSetsEqual(set1, set2) {
if (set1.size !== set2.size) return false;
const entries = Array.from(set1);
for (let i = 0; i < entries.length; i++) {
if (set2.has(entries[i]) === false) return false;
}
return true;
}

function getRenderPropFromChildren(children) {
if (Array.isArray(children)) {
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.type === 'JSXExpressionContainer' && child.expression.type === 'ArrowFunctionExpression') {
return child.expression;
}
}
} else {
if (children.type === 'JSXExpressionContainer' && children.expression.type === 'ArrowFunctionExpression') {
return children.expression;
}
}
}

function getExpectedParamNameFromToken(tokenValue) {
const tokenParts = tokenValue.split(/\./g);
return tokenParts[tokenParts.length - 1];
}

module.exports = {
meta: {
type: 'problem',

docs: {
description: 'Enforce EuiI18n token names & variable names in render prop',
},

messages: {
invalidToken: 'token value "{{ tokenValue }}" must be of format {{ tokenNamespace }}.tokenName',
mismatchedValues: 'expected values "{{ expected }}" but provided {{ provided }}',
mismatchedTokensAndDefaults: 'given {{ tokenLength }} tokens but {{ defaultsLength }} defaults',
tokenNamesNotUsedInRenderProp: 'tokens {{ tokenNames }} is not used by render prop params {{ paramNames }}',
invalidTokenType: 'token expects a string value, {{ type }} passed instead',
invalidTokensType: 'tokens expects an array of strings, {{ type }} passed instead',
invalidDefaultType: 'default expects a string or arrow function, {{ type }} passed instead',
invalidDefaultsType: 'defaults expects an array of strings or arrow functions, {{ type }} passed instead',
},
},
create: function (context) {
const filename = context.getFilename();
const basename = path.basename(filename, path.extname(filename));
const expectedTokenNamespace = `eui${basename.replace(/(^|_)([a-z])/g, (match, leading, char) => char.toUpperCase())}`;

return {
JSXOpeningElement(node) {
// only process <EuiI8n/> elements
if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'EuiI18n') return;

const jsxElement = node.parent;
const hasRenderProp = jsxElement.children.length > 0;

const attributes = attributesArrayToLookup(node.attributes);

// validate attribute types
if (attributes.hasOwnProperty('token')) {
// `token` must be a Literal
if (attributes.token.type !== 'Literal') {
context.report({
node,
loc: attributes.token.loc,
messageId: 'invalidTokenType',
data: { type: attributes.token.type }
});
return;
}
}

if (attributes.hasOwnProperty('default')) {
// default must be either a Literal of an ArrowFunctionExpression
const isLiteral = attributes.default.type === 'Literal';
const isArrowExpression =
attributes.default.type === 'JSXExpressionContainer' &&
attributes.default.expression.type === 'ArrowFunctionExpression';
if (!isLiteral && !isArrowExpression) {
context.report({
node,
loc: attributes.default.loc,
messageId: 'invalidDefaultType',
data: { type: attributes.default.expression.type }
});
return;
}
}

if (attributes.hasOwnProperty('tokens')) {
// tokens must be an array of Literals
if (attributes.tokens.type !== 'JSXExpressionContainer') {
context.report({
node,
loc: attributes.tokens.loc,
messageId: 'invalidTokensType',
data: { type: attributes.tokens.type }
});
return;
}

if (attributes.tokens.expression.type !== 'ArrayExpression') {
context.report({
node,
loc: attributes.tokens.loc,
messageId: 'invalidTokensType',
data: { type: attributes.tokens.expression.type }
});
return;
}

for (let i = 0; i < attributes.tokens.expression.elements.length; i++) {
const tokenNode = attributes.tokens.expression.elements[i];
if (tokenNode.type !== 'Literal' || typeof tokenNode.value !== 'string') {
context.report({
node,
loc: tokenNode.loc,
messageId: 'invalidTokensType',
data: { type: tokenNode.type }
});
return;
}
}
}

if (attributes.hasOwnProperty('defaults')) {
// defaults must be an array of either Literals or ArrowFunctionExpressions
if (attributes.defaults.type !== 'JSXExpressionContainer') {
context.report({
node,
loc: attributes.defaults.loc,
messageId: 'invalidDefaultsType',
data: { type: attributes.defaults.type }
});
return;
}

if (attributes.defaults.expression.type !== 'ArrayExpression') {
context.report({
node,
loc: attributes.defaults.loc,
messageId: 'invalidDefaultsType',
data: { type: attributes.defaults.expression.type }
});
return;
}

for (let i = 0; i < attributes.defaults.expression.elements.length; i++) {
const defaultNode = attributes.defaults.expression.elements[i];
if (defaultNode.type !== 'Literal' || typeof defaultNode.value !== 'string') {
console.log('::', defaultNode.value, typeof defaultNode.value);
context.report({
node,
loc: defaultNode.loc,
messageId: 'invalidDefaultsType',
data: { type: defaultNode.type }
});
return;
}
}
}

const hasMultipleTokens = attributes.hasOwnProperty('tokens');

if (!hasMultipleTokens) {
// validate token format
const tokenParts = attributes.token.value.split('.');
if (tokenParts.length <= 1 || tokenParts[0] !== expectedTokenNamespace) {
context.report({
node,
loc: attributes.token.loc,
messageId: 'invalidToken',
data: { tokenValue: attributes.token.value, tokenNamespace: expectedTokenNamespace }
});
}

// validate default string interpolation matches values
const valueNames = getDefinedValues(attributes.values);

if (attributes.default.type === 'Literal') {
// default is a string literal
const expectedNames = getExpectedValueNames(attributes.default.value);
if (areSetsEqual(expectedNames, valueNames) === false) {
context.report({
node,
loc: attributes.values.loc,
messageId: 'mismatchedValues',
data: { expected: formatSet(expectedNames), provided: formatSet(valueNames) }
});
}
} else {
// default is a function
// validate the destructured param defined by default function match the values
const defaultFn = attributes.default.expression;
const objProperties = defaultFn.params ? defaultFn.params[0].properties : [];
const expectedNames = new Set(objProperties.map(property => property.key.name));
if (areSetsEqual(valueNames, expectedNames) === false) {
context.report({
node,
loc: attributes.values.loc,
messageId: 'mismatchedValues',
data: { expected: formatSet(expectedNames), provided: formatSet(valueNames) }
});
}
}
} else {
// has multiple tokens
// validate their names
const tokens = attributes.tokens.expression.elements;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const tokenParts = token.value.split('.');
if (tokenParts.length <= 1 || tokenParts[0] !== expectedTokenNamespace) {
context.report({
node,
loc: token.loc,
messageId: 'invalidToken',
data: { tokenValue: token.value, tokenNamespace: expectedTokenNamespace }
});
}
}

// validate the number of tokens equals the number of defaults
const defaults = attributes.defaults.expression.elements;
if (tokens.length !== defaults.length) {
context.report({
node,
loc: node.loc,
messageId: 'mismatchedTokensAndDefaults',
data: { tokenLength: tokens.length, defaultsLength: defaults.length }
});
}
}

if (hasRenderProp) {
// validate the render prop
const renderProp = getRenderPropFromChildren(jsxElement.children);

if (hasMultipleTokens) {
// multiple tokens, verify each token matches an array-destructured param
const params = renderProp.params[0].elements;
const tokens = attributes.tokens.expression.elements;

const paramsSet = new Set(params.map(element => element.name));
const tokensSet = new Set(tokens.map(element => getExpectedParamNameFromToken(element.value)));

if (areSetsEqual(paramsSet, tokensSet) === false) {
context.report({
node,
loc: node.loc,
messageId: 'tokenNamesNotUsedInRenderProp',
data: { tokenNames: formatSet(tokensSet), paramNames: formatSet(paramsSet) }
});
}

} else {
// single token, single param should be a matching identifier
const param = renderProp.params[0];
const tokenName = getExpectedParamNameFromToken(attributes.token.value);
const paramName = param.name;

if (tokenName !== paramName) {
context.report({
node,
loc: node.loc,
messageId: 'tokenNamesNotUsedInRenderProp',
data: { tokenNames: tokenName, paramNames: paramName }
});
}
}
}

// debugger;
}
// callback functions
};
}
};
Loading