fix(dates): make DAY precision locale-aware in formatDocumentDate
DAY precision routed through formatDate() which hard-coded de-DE, so an
en/es reader saw the German month name ("24. Dezember 1943"). Route DAY
through Intl.DateTimeFormat(locale, …) like the other branches, keeping
the T12:00:00 UTC-safety convention. Add en/es DAY+MONTH parity cases to
docs/date-label-fixtures.json (TS-only; the Java title formatter stays
German by design) and assert them in the spec.
Refs #666
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,11 @@ type FixtureCase = {
|
||||
expected: string;
|
||||
};
|
||||
|
||||
type LocaleFixtureCase = FixtureCase & { locale: string };
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), '../docs/date-label-fixtures.json'), 'utf-8')
|
||||
) as { cases: FixtureCase[] };
|
||||
) as { cases: FixtureCase[]; localeCases: LocaleFixtureCase[] };
|
||||
|
||||
describe('formatDocumentDate – shared fixture table (de)', () => {
|
||||
for (const c of fixtures.cases) {
|
||||
@@ -37,6 +39,24 @@ describe('formatDocumentDate – shared fixture table (de)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// TS-only locale parity (the Java title formatter is German-only by design, so
|
||||
// localeCases are asserted here and never fed to DocumentTitleFormatterTest).
|
||||
describe('formatDocumentDate – shared fixture table (en/es locale parity)', () => {
|
||||
for (const c of fixtures.localeCases) {
|
||||
it(`${c.name} [${c.locale}]`, () => {
|
||||
expect(
|
||||
formatDocumentDate(
|
||||
c.anchor,
|
||||
c.precision as Parameters<typeof formatDocumentDate>[1],
|
||||
c.end,
|
||||
c.raw,
|
||||
c.locale
|
||||
)
|
||||
).toBe(c.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Anti-fabrication: suppressed components never leak ──────────────────────
|
||||
|
||||
describe('formatDocumentDate – suppressed precision components', () => {
|
||||
@@ -80,6 +100,40 @@ describe('formatDocumentDate – localization', () => {
|
||||
`${m.date_season_summer(undefined, { locale: 'en' })} 1916`
|
||||
);
|
||||
});
|
||||
|
||||
// DAY precision must honour the active locale (regression: it was hard-wired
|
||||
// to de-DE, so an English/Spanish reader saw "24. Dezember 1943").
|
||||
it('localizes the DAY month name in English', () => {
|
||||
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'en')).toBe(
|
||||
new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||
new Date('1943-12-24T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the DAY month name in Spanish', () => {
|
||||
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'es')).toBe(
|
||||
new Intl.DateTimeFormat('es', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||
new Date('1943-12-24T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the MONTH month name in English', () => {
|
||||
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'en')).toBe(
|
||||
new Intl.DateTimeFormat('en', { month: 'long', year: 'numeric' }).format(
|
||||
new Date('1916-06-01T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the MONTH month name in Spanish', () => {
|
||||
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'es')).toBe(
|
||||
new Intl.DateTimeFormat('es', { month: 'long', year: 'numeric' }).format(
|
||||
new Date('1916-06-01T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security: untrusted raw must never influence the structured label ───────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatDate, formatMCDate } from './date';
|
||||
import { formatMCDate } from './date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
/**
|
||||
@@ -10,9 +10,11 @@ export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APP
|
||||
/**
|
||||
* Renders a document date at exactly the precision the data claims — never finer.
|
||||
*
|
||||
* Delegates to the {@link formatDate}/{@link formatMCDate} helpers (so the
|
||||
* `T12:00:00` UTC-safety convention and the German Intl formatting are shared,
|
||||
* not reimplemented) and routes every localized word through Paraglide.
|
||||
* Every structured part (month name, day-of-month text, season word, prefixes)
|
||||
* is rendered in the active `locale` — DAY, MONTH and RANGE all go through
|
||||
* `Intl.DateTimeFormat(locale, …)` and the localized words through Paraglide —
|
||||
* so an `en`/`es` reader never sees a German month name. The `T12:00:00`
|
||||
* UTC-safety convention is kept via {@link noon}.
|
||||
*
|
||||
* The label is the SINGLE SOURCE OF TRUTH shared with the Java
|
||||
* {@code DocumentTitleFormatter}: both are asserted against
|
||||
@@ -42,7 +44,7 @@ export function formatDocumentDate(
|
||||
|
||||
switch (precision) {
|
||||
case 'DAY':
|
||||
return formatDate(iso, 'long');
|
||||
return longDate(iso, locale);
|
||||
case 'MONTH':
|
||||
return monthYear(iso, locale);
|
||||
case 'SEASON':
|
||||
@@ -60,6 +62,14 @@ export function formatDocumentDate(
|
||||
|
||||
// ─── precision branches ──────────────────────────────────────────────────────
|
||||
|
||||
function longDate(iso: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(noon(iso));
|
||||
}
|
||||
|
||||
function monthYear(iso: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(noon(iso));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user