@celestia-island/hikari 0.42.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/i18n/locales/ar/time.json +9 -0
- package/src/i18n/locales/de/time.json +9 -0
- package/src/i18n/locales/en/time.json +9 -0
- package/src/i18n/locales/es/time.json +9 -0
- package/src/i18n/locales/fr/time.json +9 -0
- package/src/i18n/locales/ja/time.json +9 -0
- package/src/i18n/locales/ko/time.json +9 -0
- package/src/i18n/locales/pt/time.json +9 -0
- package/src/i18n/locales/ru/time.json +9 -0
- package/src/i18n/locales/zh-Hans/time.json +9 -0
- package/src/i18n/locales/zh-Hant/time.json +9 -0
- package/src/i18n/timeKeys.test.ts +79 -0
- package/src/utils/format.test.ts +116 -0
- package/src/utils/format.ts +16 -3
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { setLocale, useI18n } from "./context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Locale key pack for the formatRelativeTime facility
|
|
7
|
+
* (common.time.justNow/minutesAgo/hoursAgo/daysAgo/weeksAgo, served from
|
|
8
|
+
* the new locales/<lang>/time.json bundles). The JSON keys are the literal
|
|
9
|
+
* unprefixed full names because t() resolves by exact full-name match
|
|
10
|
+
* (context.ts merges string leaves under their literal key) and the
|
|
11
|
+
* facility + its host-t consumers call t("common.time.*") unprefixed —
|
|
12
|
+
* the "hikari::" prefix convention is reserved for
|
|
13
|
+
* hikari-component-owned keys. A key missing from any locale silently
|
|
14
|
+
* falls back to English there, so every locale must define all five keys
|
|
15
|
+
* with matching {n} placeholder presence (hikari review lessons
|
|
16
|
+
* #373/#374, same shape as contextKeys.test.ts).
|
|
17
|
+
*/
|
|
18
|
+
const modules = import.meta.glob<{ default: Record<string, Record<string, string>> }>(
|
|
19
|
+
"./locales/*/time.json",
|
|
20
|
+
{ eager: true },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const EXPECTED_LOCALES = [
|
|
24
|
+
"ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const REQUIRED_KEYS = [
|
|
28
|
+
"common.time.justNow",
|
|
29
|
+
"common.time.minutesAgo",
|
|
30
|
+
"common.time.hoursAgo",
|
|
31
|
+
"common.time.daysAgo",
|
|
32
|
+
"common.time.weeksAgo",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe("common.time locale keys", () => {
|
|
36
|
+
const bundles = Object.entries(modules).map(([path, mod]) => ({
|
|
37
|
+
locale: path.match(/locales\/([^/]+)\/time\.json/)?.[1] ?? "",
|
|
38
|
+
flat: mod.default.time ?? {},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
it("covers all 11 time.json locale files", () => {
|
|
42
|
+
expect(bundles.map((b) => b.locale).sort()).toEqual(EXPECTED_LOCALES);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("defines all 5 common.time.* keys in every locale, non-empty", () => {
|
|
46
|
+
for (const { locale, flat } of bundles) {
|
|
47
|
+
for (const key of REQUIRED_KEYS) {
|
|
48
|
+
expect(typeof flat[key], `${locale} must define ${key}`).toBe("string");
|
|
49
|
+
expect((flat[key] ?? "").length, `${locale} ${key} must not be empty`).toBeGreaterThan(0);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("matches the {n} placeholder pattern of en in every locale", () => {
|
|
55
|
+
const en = bundles.find((b) => b.locale === "en");
|
|
56
|
+
expect(en, "en bundle renders").toBeTruthy();
|
|
57
|
+
for (const { locale, flat } of bundles) {
|
|
58
|
+
for (const key of REQUIRED_KEYS) {
|
|
59
|
+
const wantsN = en!.flat[key].includes("{n}");
|
|
60
|
+
expect(flat[key].includes("{n}"), `${locale} ${key} {n} parity`).toBe(wantsN);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("resolves through useI18n at runtime, per locale", async () => {
|
|
66
|
+
await setLocale("zh-Hans");
|
|
67
|
+
const zh = useI18n();
|
|
68
|
+
expect(zh.t("common.time.justNow", "Just now")).toBe("刚刚");
|
|
69
|
+
expect(zh.t("common.time.hoursAgo", "{n} h ago").replace("{n}", "3")).toBe("3 小时前");
|
|
70
|
+
|
|
71
|
+
await setLocale("de");
|
|
72
|
+
const de = useI18n();
|
|
73
|
+
expect(de.t("common.time.daysAgo", "{n} d ago").replace("{n}", "2")).toBe("vor 2 Tg.");
|
|
74
|
+
|
|
75
|
+
await setLocale("en");
|
|
76
|
+
const en = useI18n();
|
|
77
|
+
expect(en.t("common.time.weeksAgo", "{n} w ago")).toBe("{n} w ago");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { formatRelativeTime, type RelativeTimeT } from "./format";
|
|
4
|
+
|
|
5
|
+
const MIN = 60_000;
|
|
6
|
+
const HOUR = 3_600_000;
|
|
7
|
+
const DAY = 86_400_000;
|
|
8
|
+
|
|
9
|
+
function ago(ms: number): Date {
|
|
10
|
+
return new Date(Date.now() - ms);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CapturedCall {
|
|
14
|
+
key: string;
|
|
15
|
+
fallback: string;
|
|
16
|
+
named?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fake translator: records every lookup and renders the {n} slot so
|
|
20
|
+
* injection can be asserted end-to-end. */
|
|
21
|
+
function makeCaptureT(): { t: RelativeTimeT; calls: CapturedCall[] } {
|
|
22
|
+
const calls: CapturedCall[] = [];
|
|
23
|
+
const t: RelativeTimeT = (key, fallback, named) => {
|
|
24
|
+
calls.push({ key, fallback, named });
|
|
25
|
+
return fallback.replace("{n}", String(named?.n ?? ""));
|
|
26
|
+
};
|
|
27
|
+
return { t, calls };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("formatRelativeTime", () => {
|
|
31
|
+
it("returns an empty string for missing input", () => {
|
|
32
|
+
expect(formatRelativeTime("")).toBe("");
|
|
33
|
+
expect(formatRelativeTime(0)).toBe("");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns an empty string for unparseable input", () => {
|
|
37
|
+
expect(formatRelativeTime("not-a-date")).toBe("");
|
|
38
|
+
expect(formatRelativeTime(Number.NaN)).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders the compact English fallback per tier", () => {
|
|
42
|
+
expect(formatRelativeTime(ago(59_000))).toBe("Just now");
|
|
43
|
+
expect(formatRelativeTime(ago(MIN))).toBe("1m ago");
|
|
44
|
+
expect(formatRelativeTime(ago(59 * MIN))).toBe("59m ago");
|
|
45
|
+
expect(formatRelativeTime(ago(HOUR))).toBe("1h ago");
|
|
46
|
+
expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h ago");
|
|
47
|
+
expect(formatRelativeTime(ago(DAY))).toBe("1d ago");
|
|
48
|
+
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d ago");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("adds the weeks tier from 7d up to (excluding) 30d", () => {
|
|
52
|
+
expect(formatRelativeTime(ago(7 * DAY))).toBe("1w ago");
|
|
53
|
+
expect(formatRelativeTime(ago(13 * DAY))).toBe("1w ago");
|
|
54
|
+
expect(formatRelativeTime(ago(14 * DAY))).toBe("2w ago");
|
|
55
|
+
expect(formatRelativeTime(ago(29 * DAY))).toBe("4w ago");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls to the absolute-date path at >= 30d", () => {
|
|
59
|
+
const d = ago(30 * DAY);
|
|
60
|
+
// Do not pin a locale-specific rendering; assert the value comes
|
|
61
|
+
// from the same Date's toLocaleDateString().
|
|
62
|
+
expect(formatRelativeTime(d)).toBe(d.toLocaleDateString());
|
|
63
|
+
expect(formatRelativeTime(d).length).toBeGreaterThan(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("clamps future timestamps into the justNow tier", () => {
|
|
67
|
+
expect(formatRelativeTime(ago(-5 * MIN))).toBe("Just now");
|
|
68
|
+
expect(formatRelativeTime(ago(-2 * DAY))).toBe("Just now");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("passes the documented keys with injected {n} to the translator", () => {
|
|
72
|
+
const { t, calls } = makeCaptureT();
|
|
73
|
+
|
|
74
|
+
expect(formatRelativeTime(ago(30_000), t)).toBe("Just now");
|
|
75
|
+
expect(calls[0]).toEqual({
|
|
76
|
+
key: "common.time.justNow",
|
|
77
|
+
fallback: "Just now",
|
|
78
|
+
named: undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(formatRelativeTime(ago(5 * MIN), t)).toBe("5 min ago");
|
|
82
|
+
expect(calls[1]).toEqual({
|
|
83
|
+
key: "common.time.minutesAgo",
|
|
84
|
+
fallback: "{n} min ago",
|
|
85
|
+
named: { n: 5 },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(formatRelativeTime(ago(3 * HOUR), t)).toBe("3 h ago");
|
|
89
|
+
expect(calls[2]).toEqual({
|
|
90
|
+
key: "common.time.hoursAgo",
|
|
91
|
+
fallback: "{n} h ago",
|
|
92
|
+
named: { n: 3 },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(formatRelativeTime(ago(2 * DAY), t)).toBe("2 d ago");
|
|
96
|
+
expect(calls[3]).toEqual({
|
|
97
|
+
key: "common.time.daysAgo",
|
|
98
|
+
fallback: "{n} d ago",
|
|
99
|
+
named: { n: 2 },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(formatRelativeTime(ago(8 * DAY), t)).toBe("1 w ago");
|
|
103
|
+
expect(calls[4]).toEqual({
|
|
104
|
+
key: "common.time.weeksAgo",
|
|
105
|
+
fallback: "{n} w ago",
|
|
106
|
+
named: { n: 1 },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps absolute dates away from the translator", () => {
|
|
111
|
+
const { t, calls } = makeCaptureT();
|
|
112
|
+
const d = ago(45 * DAY);
|
|
113
|
+
expect(formatRelativeTime(d, t)).toBe(d.toLocaleDateString());
|
|
114
|
+
expect(calls).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -37,7 +37,8 @@ export function formatPriceUsd(n: number, currency = "$"): string {
|
|
|
37
37
|
return `${currency}${n.toFixed(2)}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Timestamp -> "Just now" / "5m ago" / "3h ago" / "2d ago" /
|
|
40
|
+
/** Timestamp -> "Just now" / "5m ago" / "3h ago" / "2d ago" / "2w ago" /
|
|
41
|
+
* locale date. */
|
|
41
42
|
export type RelativeTimeT = (
|
|
42
43
|
key: string,
|
|
43
44
|
fallback: string,
|
|
@@ -46,7 +47,12 @@ export type RelativeTimeT = (
|
|
|
46
47
|
|
|
47
48
|
/** Relative-time formatting ("just now / {n} min ago / …").
|
|
48
49
|
* Pass an optional translator for localized variants; defaults to
|
|
49
|
-
* compact English text.
|
|
50
|
+
* compact English text. Tiers: <1min justNow, <60min minutes,
|
|
51
|
+
* <24h hours, <7d days, <30d weeks, otherwise an absolute
|
|
52
|
+
* locale-rendered date (browser locale, like formatDateTime).
|
|
53
|
+
* The translator owns {n} interpolation — hikari's own useI18n().t
|
|
54
|
+
* does NOT interpolate named params, so wrap it (the canonical key
|
|
55
|
+
* set lives in the per-locale i18n time bundles). */
|
|
50
56
|
export function formatRelativeTime(
|
|
51
57
|
input: string | number | Date,
|
|
52
58
|
t?: RelativeTimeT,
|
|
@@ -54,7 +60,8 @@ export function formatRelativeTime(
|
|
|
54
60
|
if (!input) return "";
|
|
55
61
|
const d = input instanceof Date ? input : new Date(input);
|
|
56
62
|
if (isNaN(d.getTime())) return "";
|
|
57
|
-
|
|
63
|
+
// Clamp future timestamps to "just now" — no negative weeks/days.
|
|
64
|
+
const diff = Math.max(0, Date.now() - d.getTime());
|
|
58
65
|
const mins = Math.floor(diff / 60000);
|
|
59
66
|
const hours = Math.floor(diff / 3600000);
|
|
60
67
|
const days = Math.floor(diff / 86400000);
|
|
@@ -62,6 +69,12 @@ export function formatRelativeTime(
|
|
|
62
69
|
if (mins < 60) return t?.("common.time.minutesAgo", "{n} min ago", { n: mins }) ?? `${mins}m ago`;
|
|
63
70
|
if (hours < 24) return t?.("common.time.hoursAgo", "{n} h ago", { n: hours }) ?? `${hours}h ago`;
|
|
64
71
|
if (days < 7) return t?.("common.time.daysAgo", "{n} d ago", { n: days }) ?? `${days}d ago`;
|
|
72
|
+
if (days < 30) {
|
|
73
|
+
const weeks = Math.floor(days / 7);
|
|
74
|
+
return (
|
|
75
|
+
t?.("common.time.weeksAgo", "{n} w ago", { n: weeks }) ?? `${weeks}w ago`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
65
78
|
return d.toLocaleDateString();
|
|
66
79
|
}
|
|
67
80
|
|