Created
November 4, 2023 23:43
-
-
Save enagy27/1a1de1b8c3e5a79573b9cc9602f07de4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { TSESTree } from "@typescript-eslint/typescript-estree" | |
import { Rule } from "eslint" | |
import { isNotNullish } from "@notionhq/shared/typeUtils" | |
function closest( | |
node: TSESTree.Node, | |
predicate: (node: TSESTree.Node) => boolean | |
) { | |
let current = node | |
while (current.parent) { | |
if (!predicate(current.parent)) { | |
current = current.parent | |
continue | |
} | |
return current.parent | |
} | |
return null | |
} | |
function isStaticTemplateLiteral(node: TSESTree.Node): boolean { | |
return ( | |
node.type === "TemplateLiteral" && | |
node.expressions.length < 1 && | |
node.quasis.length === 1 | |
) | |
} | |
function getStaticTemplateLiteralValueAsString( | |
node: TSESTree.Node | |
): string | null { | |
if (node.type !== "TemplateLiteral" && node.quasis.length !== 1) { | |
return null | |
} | |
const [{ value }] = node.quasis | |
return value.cooked | |
} | |
function getStringValue( | |
node: TSESTree.Literal | TSESTree.Identifier | TSESTree.TemplateLiteral | |
) { | |
if (node.type === "Literal") { | |
return node.value | |
} | |
if (node.type === "Identifier") { | |
return node.name | |
} | |
return isStaticTemplateLiteral(node) | |
? getStaticTemplateLiteralValueAsString(node) | |
: null | |
} | |
const escapeSequence = /\{\w+\}/g | |
const multipleWhitespace = /\s{2,}/gm | |
function ensurePropertyValueFormat( | |
knownSequence: `{${string}}`, | |
property: TSESTree.Property | |
): string | null { | |
const value = getStringValue(property.value) | |
if (multipleWhitespace.test(value)) { | |
return "Multiple whitespace characters are not allowed." | |
} | |
const escapeSequences = Array.from(value.matchAll(escapeSequence)).flatMap( | |
([sequence]) => sequence | |
) | |
const [unknownEscapeSequence] = escapeSequences.filter( | |
sequence => sequence !== knownSequence | |
) | |
if (unknownEscapeSequence) { | |
return `Unknown escape sequence: ${unknownEscapeSequence}` | |
} | |
} | |
function findValuesInJSXAttribute( | |
node: TSESTree.CallExpression | |
): TSESTree.ObjectExpression | null { | |
const defaultMessageAttribute = closest( | |
node, | |
ancestor => | |
ancestor.type === "JSXAttribute" && | |
ancestor.name.name === "defaultMessage" | |
) as TSESTree.JSXAttribute | null | |
if (!defaultMessageAttribute) { | |
return null | |
} | |
const openingElementWithDefaultMessage = defaultMessageAttribute.parent | |
if (openingElementWithDefaultMessage?.type !== "JSXOpeningElement") { | |
return null | |
} | |
const valuesAttribute = openingElementWithDefaultMessage.attributes.find( | |
attr => { | |
return attr.type === "JSXAttribute" && attr.name.name === "values" | |
} | |
) as TSESTree.JSXAttribute | undefined | |
if (!valuesAttribute) { | |
return null | |
} | |
return valuesAttribute.value?.type === "JSXExpressionContainer" && | |
valuesAttribute.value.expression.type === "ObjectExpression" | |
? valuesAttribute.value.expression | |
: null | |
} | |
function findValuesInCallExpression( | |
node: TSESTree.CallExpression | |
): TSESTree.ObjectExpression | null { | |
const defaultMessageProperty = closest( | |
node, | |
ancestor => | |
ancestor.type === "Property" && | |
ancestor.key.type === "Identifier" && | |
ancestor.key.name === "defaultMessage" | |
) as TSESTree.Property | null | |
if (!defaultMessageProperty) { | |
return null | |
} | |
const objectExpressionWithDefaultMessage = defaultMessageProperty.parent | |
if (objectExpressionWithDefaultMessage?.type !== "ObjectExpression") { | |
return null | |
} | |
const callExpression = objectExpressionWithDefaultMessage.parent | |
if (callExpression?.type !== "CallExpression") { | |
return null | |
} | |
const [, valuesArgument] = callExpression.arguments | |
if (valuesArgument.type !== "ObjectExpression") { | |
return null | |
} | |
return valuesArgument | |
} | |
function ensureValuesContainsKey( | |
node: TSESTree.CallExpression, | |
key: string | |
): string | null { | |
const valuesObjectExpression = | |
findValuesInJSXAttribute(node) ?? findValuesInCallExpression(node) | |
if (!valuesObjectExpression) { | |
return "plural must be called directly within a formatMessage or FormattedMessage's defaultMessage. These format calls must also have a values argument with an object literal in their corresponding function call or react component. These cannot be references because they must be statically analyzable." | |
} | |
const hasValueForKey = valuesObjectExpression.properties.some(property => { | |
return property.type === "Property" && getStringValue(property.key) === key | |
}) | |
if (!hasValueForKey) { | |
const hasSpreadInValues = valuesObjectExpression.properties.some( | |
property => property.type === "SpreadElement" | |
) | |
return [ | |
hasSpreadInValues | |
? "The values argument cannot have a spread because it is not statically analyzable." | |
: null, | |
`No corresponding key was found in values for "${key}" in the corresponding function call or react component.`, | |
] | |
.filter(isNotNullish) | |
.join(" ") | |
} | |
return null | |
} | |
const assumedConfig = { | |
"formatjs/enforce-default-message": "error", | |
"formatjs/enforce-placeholders": "error", | |
"formatjs/enforce-plural-rules": [ | |
"error", | |
{ | |
one: true, | |
other: true, | |
}, | |
], | |
"formatjs/no-multiple-whitespaces": "error", | |
"notion/ensure-valid-intl-plural-calls": "error", | |
} as const | |
const rule: Rule.RuleModule = { | |
meta: { | |
type: "problem", | |
docs: { | |
description: [ | |
"Ensure that intl.plural calls are valid.", | |
"Note that while this is not currently configurable, it assumes the following config:", | |
JSON.stringify(assumedConfig, null, 2).split("\n").join("\n "), | |
], | |
}, | |
fixable: "code", | |
schema: [], // no options | |
}, | |
create: context => { | |
function visitor(node: TSESTree.CallExpression) { | |
if (node.callee.type !== "Identifier" || node.callee.name !== "plural") { | |
return | |
} | |
const [key, matches] = node.arguments as Array< | |
TSESTree.CallExpressionArgument | undefined | |
> | |
if ( | |
key?.type !== "Literal" || | |
matches?.type !== "ObjectExpression" || | |
!matches.properties.every(property => { | |
if (property.type !== "Property") { | |
return false | |
} | |
if ( | |
property.key.type !== "Literal" && | |
property.key.type !== "Identifier" | |
) { | |
return false | |
} | |
return ( | |
property.value.type === "Literal" || | |
isStaticTemplateLiteral(property.value) | |
) | |
}) | |
) { | |
context.report({ | |
node: node as any, | |
message: | |
"plural calls must have a literal key and an object literal with string keys. References to values or computed properties are not allowed as they are not statically analyzable.", | |
}) | |
return | |
} | |
const keyValue = getStringValue(key) | |
const knownSequence = `{${keyValue}}` as const | |
for (const property of matches.properties) { | |
const reportPropertyValueFormat = ensurePropertyValueFormat( | |
knownSequence, | |
property | |
) | |
if (reportPropertyValueFormat) { | |
context.report({ | |
node: property.value, | |
message: reportPropertyValueFormat, | |
}) | |
return | |
} | |
} | |
const reportValuesExcludesKey = ensureValuesContainsKey(node, keyValue) | |
if (reportValuesExcludesKey) { | |
context.report({ | |
node: node as any, | |
message: reportValuesExcludesKey, | |
}) | |
return | |
} | |
} | |
return { | |
CallExpression: (node: any) => visitor(node), | |
} | |
}, | |
} | |
export default rule |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment