fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
This commit is contained in:
+48
-15
@@ -82,33 +82,66 @@ const focusSafeDisabled = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
|
||||
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
|
||||
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
|
||||
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
|
||||
// number goes through formatNumber, which the same rule covers).
|
||||
const dateFormat = {
|
||||
// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
|
||||
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
|
||||
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
|
||||
// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
|
||||
// alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused,
|
||||
// an `Intl.Collator` outside collate.ts, and a `localeCompare` anywhere.
|
||||
//
|
||||
// And in a component, the language itself must come off `useTranslation()`, never the i18n singleton
|
||||
// (`import i18n from "../i18n"`): the React Compiler memoizes on what it can see, and a singleton read
|
||||
// is invisible to it — a card kept "2 hr 4 min" and a Plex query its English after a switch to
|
||||
// Hungarian. The singleton is resolved by SCOPE, so `const { i18n } = useTranslation()` passes.
|
||||
const localeFormat = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
|
||||
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatNumber, formatDecimal, …), in the UI language.",
|
||||
order:
|
||||
"Compare text with compareText (lib/collate.ts), in the UI language — a bare localeCompare sorts in the browser's.",
|
||||
singleton:
|
||||
"Take the language from useTranslation() (`const { i18n } = useTranslation()`), not the i18n singleton — the React Compiler cannot see a singleton read, so the value stays in the old language after a switch.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const path = context.filename.split("\\").join("/");
|
||||
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
|
||||
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
|
||||
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
|
||||
if (/\.test\.tsx?$/.test(path)) return {};
|
||||
// lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts)
|
||||
// is a clock face with its own 12/24h switch, not a date.
|
||||
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
|
||||
const collates = /\/src\/lib\/collate\.ts$/.test(path);
|
||||
const component = /\/src\/components\//.test(path);
|
||||
const importedFromI18n = (node) => {
|
||||
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
|
||||
const v = scope.set.get(node.name);
|
||||
if (v)
|
||||
return v.defs.some(
|
||||
(d) => d.type === "ImportBinding" && /\/i18n$|^i18next$/.test(d.parent.source.value),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return {
|
||||
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
|
||||
node,
|
||||
) {
|
||||
context.report({ node, messageId: "date" });
|
||||
if (!formats) context.report({ node, messageId: "date" });
|
||||
},
|
||||
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
|
||||
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat|NumberFormat)$/]"(
|
||||
node,
|
||||
) {
|
||||
context.report({ node, messageId: "date" });
|
||||
if (!formats) context.report({ node, messageId: "date" });
|
||||
},
|
||||
"MemberExpression[object.name='Intl'][property.name='Collator']"(node) {
|
||||
if (!collates) context.report({ node, messageId: "order" });
|
||||
},
|
||||
"CallExpression > MemberExpression.callee[property.name='localeCompare']"(node) {
|
||||
context.report({ node, messageId: "order" });
|
||||
},
|
||||
"MemberExpression[property.name='language'][object.type='Identifier']"(node) {
|
||||
if (component && importedFromI18n(node.object))
|
||||
context.report({ node, messageId: "singleton" });
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -382,7 +415,7 @@ const localRules = {
|
||||
"jsx-comment-text": jsxCommentText,
|
||||
"focus-safe-disabled": focusSafeDisabled,
|
||||
"button-primitive": buttonPrimitive,
|
||||
"date-format": dateFormat,
|
||||
"locale-format": localeFormat,
|
||||
"input-primitive": inputPrimitive,
|
||||
"surface-role": surfaceRole,
|
||||
"literal-a11y-text": literalA11yText,
|
||||
@@ -477,7 +510,7 @@ export default tseslint.config(
|
||||
"local/jsx-comment-text": "error",
|
||||
"local/focus-safe-disabled": "error",
|
||||
"local/button-primitive": "error",
|
||||
"local/date-format": "error",
|
||||
"local/locale-format": "error",
|
||||
"local/input-primitive": "error",
|
||||
"local/surface-role": "error",
|
||||
"local/literal-a11y-text": "error",
|
||||
|
||||
Reference in New Issue
Block a user