Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
194 lines
5.3 KiB
TypeScript
194 lines
5.3 KiB
TypeScript
import { defineRule } from '@oxlint/plugins';
|
|
|
|
export const RULE_NAME = 'no-miscased-acronym-in-message';
|
|
|
|
const MESSAGE_TAG_NAMES = ['t', 'msg', 'defineMessage'];
|
|
|
|
const CORRECT_CASING: Record<string, string> = {
|
|
Api: 'API',
|
|
Apis: 'APIs',
|
|
Cdn: 'CDN',
|
|
Cname: 'CNAME',
|
|
Cors: 'CORS',
|
|
Crm: 'CRM',
|
|
Csv: 'CSV',
|
|
Dkim: 'DKIM',
|
|
Dmarc: 'DMARC',
|
|
Dns: 'DNS',
|
|
Html: 'HTML',
|
|
Http: 'HTTP',
|
|
Https: 'HTTPS',
|
|
Id: 'ID',
|
|
Ids: 'IDs',
|
|
Imap: 'IMAP',
|
|
Json: 'JSON',
|
|
Jwt: 'JWT',
|
|
Mfa: 'MFA',
|
|
Oauth: 'OAuth',
|
|
Otp: 'OTP',
|
|
Pdf: 'PDF',
|
|
Saml: 'SAML',
|
|
Sdk: 'SDK',
|
|
Smtp: 'SMTP',
|
|
Sql: 'SQL',
|
|
Sso: 'SSO',
|
|
Ssl: 'SSL',
|
|
Tls: 'TLS',
|
|
Totp: 'TOTP',
|
|
Ttl: 'TTL',
|
|
Uid: 'UID',
|
|
Url: 'URL',
|
|
Urls: 'URLs',
|
|
Uuid: 'UUID',
|
|
Xml: 'XML',
|
|
Yaml: 'YAML',
|
|
};
|
|
|
|
// `}` joins the boundary characters so that `${fieldName}Id` is left alone: the
|
|
// acronym there is the tail of a code identifier the sentence is naming, not an
|
|
// English word. The trailing guard rejects `Identity` and `Urls` alike - `Urls`
|
|
// only matches because the map lists it in its own right.
|
|
const MISCASED_ACRONYM_REGEX = new RegExp(
|
|
`(?<![A-Za-z}])(${Object.keys(CORRECT_CASING).join('|')})(?![a-z])`,
|
|
'g',
|
|
);
|
|
|
|
const getTagName = (tag: any): string | undefined => {
|
|
if (tag.type === 'Identifier') {
|
|
return tag.name;
|
|
}
|
|
|
|
// useLingui() hands the macro back on an object, so `i18n.t` and `_.t` reach
|
|
// the extractor exactly as a bare tag does.
|
|
if (tag.type === 'MemberExpression' && tag.property.type === 'Identifier') {
|
|
return tag.property.name;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const findMiscasedAcronyms = (text: string): string[] => [
|
|
...new Set([...text.matchAll(MISCASED_ACRONYM_REGEX)].map(([word]) => word)),
|
|
];
|
|
|
|
// A template literal reaches the rule already split at its placeholders, so the
|
|
// `Id` of `${fieldName}Id` opens its own chunk with nothing in front of it and
|
|
// the boundary guard would wave it through. Putting the brace back restores the
|
|
// context the split removed.
|
|
const withPrecedingPlaceholder = (
|
|
text: string,
|
|
isPrecededByPlaceholder: boolean,
|
|
): string => (isPrecededByPlaceholder ? `}${text}` : text);
|
|
|
|
export const rule = defineRule({
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description:
|
|
'Disallow title-cased acronyms in translatable source strings, which translators render correctly and Crowdin then reports as a mismatch.',
|
|
},
|
|
messages: {
|
|
miscasedAcronym:
|
|
'Write {{corrected}} rather than {{found}} in a translatable string. Translators render the acronym correctly in their own language, so the miscased English is what Crowdin reports as a QA failure - and it ships to users as a wrong label.',
|
|
},
|
|
schema: [],
|
|
},
|
|
create: (context) => {
|
|
const reportMiscasedAcronyms = (
|
|
node: any,
|
|
text: string,
|
|
isPrecededByPlaceholder = false,
|
|
) => {
|
|
for (const found of findMiscasedAcronyms(
|
|
withPrecedingPlaceholder(text, isPrecededByPlaceholder),
|
|
)) {
|
|
context.report({
|
|
node,
|
|
messageId: 'miscasedAcronym',
|
|
data: { found, corrected: CORRECT_CASING[found] },
|
|
});
|
|
}
|
|
};
|
|
|
|
return {
|
|
TaggedTemplateExpression: (node: any) => {
|
|
const tagName = getTagName(node.tag);
|
|
|
|
if (tagName === undefined || !MESSAGE_TAG_NAMES.includes(tagName)) {
|
|
return;
|
|
}
|
|
|
|
node.quasi.quasis.forEach((quasi: any, index: number) => {
|
|
reportMiscasedAcronyms(
|
|
quasi,
|
|
quasi.value.cooked ?? quasi.value.raw,
|
|
index > 0,
|
|
);
|
|
});
|
|
},
|
|
|
|
// The descriptor form the standard field metadata uses, and where this
|
|
// defect collected.
|
|
CallExpression: (node: any) => {
|
|
const calleeName = getTagName(node.callee);
|
|
|
|
if (
|
|
calleeName === undefined ||
|
|
!MESSAGE_TAG_NAMES.includes(calleeName)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const [argument] = node.arguments;
|
|
|
|
if (argument?.type !== 'ObjectExpression') {
|
|
return;
|
|
}
|
|
|
|
for (const property of argument.properties) {
|
|
if (
|
|
property.type !== 'Property' ||
|
|
property.key.type !== 'Identifier' ||
|
|
property.key.name !== 'message'
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const { value } = property;
|
|
|
|
if (value.type === 'TemplateLiteral') {
|
|
value.quasis.forEach((quasi: any, index: number) => {
|
|
reportMiscasedAcronyms(
|
|
quasi,
|
|
quasi.value.cooked ?? quasi.value.raw,
|
|
index > 0,
|
|
);
|
|
});
|
|
}
|
|
|
|
if (value.type === 'Literal' && typeof value.value === 'string') {
|
|
reportMiscasedAcronyms(value, value.value);
|
|
}
|
|
}
|
|
},
|
|
|
|
// Trans children only: plain JSX text is not a translatable string, so
|
|
// flagging it here would report a different problem under this rule's name.
|
|
JSXElement: (node: any) => {
|
|
if (
|
|
node.openingElement.name.type !== 'JSXIdentifier' ||
|
|
node.openingElement.name.name !== 'Trans'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
for (const child of node.children) {
|
|
if (child.type === 'JSXText') {
|
|
reportMiscasedAcronyms(child, child.value);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
});
|