Skip to content

Instantly share code, notes, and snippets.

@enagy27
Created November 4, 2023 23:43
Show Gist options
  • Save enagy27/1a1de1b8c3e5a79573b9cc9602f07de4 to your computer and use it in GitHub Desktop.
Save enagy27/1a1de1b8c3e5a79573b9cc9602f07de4 to your computer and use it in GitHub Desktop.
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