Commit 232f8c942f7 for woocommerce
commit 232f8c942f78d0caefc6187f84251a9ec2756488
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Thu Aug 27 14:03:47 2026 +0300
Keep the Analytics range end when the date format uses a localized moment token (#67875)
* Expand localized moment format tokens before building the day range label
* Match moment's six pass limit when expanding localized format tokens
* Cover backslash escaped localized format tokens in the range label tests
* Stop the expansion helper docblock from overclaiming its return value
* Drop the unused capture groups from the localized token pattern
* Keep the day format token so inflecting locales render the genitive month name
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Render the month name before the day swap so the label keeps start-date tokens
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Escape the whole token a backslash precedes, not just its first character
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Cover the genitive short month name of a MMM format
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cover a localized format token that expands to another localized token
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Note why weekday names are left for moment to render
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cap the escaped day run at moment's longest day token
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cover the genitive month name behind a localized format token
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cover the escaped day run cap against moment's tokenization
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cover a bracketed month literal in the range label
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Restore the day of year case to the null fallback comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Escape weekday names so substituted brackets cannot flip their form
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/packages/js/date/src/index.ts b/packages/js/date/src/index.ts
index 727e88913ba..3d14066b500 100644
--- a/packages/js/date/src/index.ts
+++ b/packages/js/date/src/index.ts
@@ -133,6 +133,105 @@ export function toMoment( format: string, str: unknown ) {
throw new Error( 'toMoment requires a string to be passed as an argument' );
}
+/**
+ * Expands moment's localized format tokens ("L", "LL", "ll", ...) into the
+ * underlying format the locale defines for them.
+ *
+ * Moment resolves those tokens only while formatting, so a day rendered through
+ * one is invisible to the day token scan below and the range end would be
+ * dropped. This mirrors moment's own expansion, including its pass limit, so
+ * the expanded format renders exactly what the original one would.
+ *
+ * @param {string} format - localized date string format
+ * @param {moment.Locale} localeData - locale the format will be rendered with
+ * @return {string} - format string with its localized tokens expanded, leaving
+ * escaped and bracketed ones as the literals they are
+ */
+function expandLocalizedFormat( format: string, localeData: moment.Locale ) {
+ // Bracketed sections and backslash escapes are moment's literals, so an "L"
+ // inside one is text; matching them first leaves them untouched, as
+ // `longDateFormat` has no entry for them.
+ const localizedTokens = /\[[^[]*\]|\\?(?:LTS|LT|LL?L?L?|l{1,4})/g;
+ let expanded = format;
+ // An expansion can itself hold localized tokens; moment allows six passes.
+ let passes = 6;
+
+ while ( passes-- > 0 ) {
+ localizedTokens.lastIndex = 0;
+
+ if ( ! localizedTokens.test( expanded ) ) {
+ break;
+ }
+
+ expanded = expanded.replace(
+ localizedTokens,
+ ( token ) =>
+ localeData.longDateFormat(
+ token as moment.LongDateFormatKey
+ ) || token
+ );
+ }
+
+ return expanded;
+}
+
+/**
+ * Renders the month and weekday names of a moment format string into escaped
+ * literals.
+ *
+ * Moment picks the grammatical form of both names by pattern-testing the
+ * format string while rendering: month choosers look for a day token next to
+ * the month one, and Ukrainian renders the genitive weekday whenever a
+ * bracketed literal sits before "dddd" - exactly the shape the substitutions
+ * here leave behind. Months and weekdays are the only tokens moment resolves
+ * against the format, so rendering every name in one pass, against the format
+ * as the locale received it, settles each choice before any substitution can
+ * flip one.
+ *
+ * @param {string} format - localized date string format
+ * @param {moment.Moment} date - date whose month and weekday to render
+ * @param {moment.Locale} localeData - locale the format will be rendered with
+ * @return {string} - format string with its month and weekday tokens escaped
+ */
+function escapeNameTokens(
+ format: string,
+ date: moment.Moment,
+ localeData: moment.Locale
+) {
+ // Backslash escapes and bracketed sections are moment's literals, so an
+ // "M" or "d" inside one is text. A backslash escapes the whole token that
+ // follows it; the escaped alternatives mirror moment's own tokens. "MM",
+ // "M", "Mo", "do" and "d" render digits, which carry no grammar.
+ return format.replace(
+ /\\(?:Mo|MM?M?M?|ddd?d?|do?)|\\.|\[[^\]]*\]|M{3,4}|d{2,4}/g,
+ ( token ) => {
+ if ( token.startsWith( 'M' ) ) {
+ const name =
+ token.length === 4
+ ? localeData.months( date, format )
+ : localeData.monthsShort( date, format );
+
+ return `[${ name }]`;
+ }
+
+ if ( ! token.startsWith( 'd' ) ) {
+ return token;
+ }
+
+ if ( token.length === 4 ) {
+ return `[${ localeData.weekdays( date, format ) }]`;
+ }
+
+ const name =
+ token.length === 3
+ ? localeData.weekdaysShort( date )
+ : localeData.weekdaysMin( date );
+
+ return `[${ name }]`;
+ }
+ );
+}
+
/**
* Swaps the day of month token of a moment format string for an escaped literal.
*
@@ -152,9 +251,11 @@ function replaceDayToken(
) {
let replaced = false;
// Backslash escapes and bracketed sections are moment's literals, so a "D"
- // inside one is text.
+ // inside one is text. A backslash escapes the whole token that follows it,
+ // not just its first character; the escaped alternatives mirror moment's
+ // own day tokens, so a longer run of "D"s leaves the rest live.
const dayRangeFormat = format.replace(
- /\\.|\[[^\]]*\]|D+o?/g,
+ /\\(?:Do|DDDo|DD?D?D?)|\\.|\[[^\]]*\]|D+o?/g,
( token ) => {
// Runs longer than "DD" are day of year tokens, not day of month.
const dayDigits = token.endsWith( 'o' )
@@ -197,16 +298,22 @@ export function getRangeLabel( after: moment.Moment, before: moment.Moment ) {
} else if ( isSameMonth ) {
// Formatting each day through the token it replaces keeps whatever the
// format asked for, such as the zero padding of "DD" or the ordinal of "Do".
+ // Everything else still renders from `after`, so a weekday, week number
+ // or time in the format stays the one the range starts on.
+ const localeData = after.localeData();
const dayRangeFormat = replaceDayToken(
- fullDateFormat,
+ escapeNameTokens(
+ expandLocalizedFormat( fullDateFormat, localeData ),
+ after,
+ localeData
+ ),
( dayToken ) =>
`${ after.format( dayToken ) } - ${ before.format( dayToken ) }`
);
- // No day of month token to swap: the format either omits the day, or
- // renders one through an aggregate token such as "LL" that is not
- // scanned. Either way the shared month is as much of the range as this
- // format can carry.
+ // No day of month token to swap: the format either omits the day or
+ // holds only a day of year token, which is left alone. Either way the
+ // shared month is as much of the range as this format can carry.
if ( dayRangeFormat === null ) {
return after.format( fullDateFormat );
}
diff --git a/packages/js/date/src/test/index.ts b/packages/js/date/src/test/index.ts
index f23f58f953d..b6a790d5ed3 100644
--- a/packages/js/date/src/test/index.ts
+++ b/packages/js/date/src/test/index.ts
@@ -839,6 +839,53 @@ describe( 'getRangeLabel', () => {
expect( label ).toBe( '2024年10月' );
} );
+ it( 'should keep the range when the format uses a localized format token', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( 'LL' );
+
+ const label = getRangeLabel(
+ moment( '2024-10-01' ),
+ moment( '2024-10-31' )
+ );
+
+ expect( label ).toBe( 'October 1 - 31, 2024' );
+ } );
+
+ it( 'should keep the range when the format uses an abbreviated localized format token', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( 'll' );
+
+ const label = getRangeLabel(
+ moment( '2024-10-01' ),
+ moment( '2024-10-31' )
+ );
+
+ expect( label ).toBe( 'Oct 1 - 31, 2024' );
+ } );
+
+ it( 'should leave a bracketed localized format token alone', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( '[LL] MMM D, YYYY' );
+
+ const label = getRangeLabel(
+ moment( '2024-10-01' ),
+ moment( '2024-10-31' )
+ );
+
+ expect( label ).toBe( 'LL Oct 1 - 31, 2024' );
+ } );
+
+ it( 'should leave a backslash escaped localized format token alone', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( '\\LL MMM D, YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'LL Oct 1 - 31, 2024' );
+
+ ( __ as jest.Mock ).mockReturnValueOnce( '\\L\\L MMM D, YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'LL Oct 1 - 31, 2024' );
+ } );
+
it( 'should keep the zero padding a "DD" format asks for', () => {
// Mirrors the Serbian translation of the format.
( __ as jest.Mock ).mockReturnValueOnce( 'DD. MMM YYYY.' );
@@ -873,6 +920,17 @@ describe( 'getRangeLabel', () => {
expect( label ).toBe( 'Day Apr 1 - 15, 2018' );
} );
+ it( 'should leave a bracketed month literal alone', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( '[MMMM] MMM D, YYYY' );
+
+ const label = getRangeLabel(
+ moment( '2018-04-01' ),
+ moment( '2018-04-15' )
+ );
+
+ expect( label ).toBe( 'MMMM Apr 1 - 15, 2018' );
+ } );
+
it( 'should leave a backslash escaped day literal alone', () => {
( __ as jest.Mock ).mockReturnValueOnce( '\\D MMM D, YYYY' );
@@ -1012,6 +1070,268 @@ describe( 'getRangeLabel', () => {
expect( label ).toBe( 'أكتوبر ١, ٢٠٢٤' );
} );
} );
+ it( 'should leave a whole backslash escaped token alone', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( '\\MMMM MMM D, YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'MMMM Oct 1 - 31, 2024' );
+
+ ( __ as jest.Mock ).mockReturnValueOnce( '\\DD MMM D, YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'DD Oct 1 - 31, 2024' );
+
+ ( __ as jest.Mock ).mockReturnValueOnce( '\\DDDo MMM D, YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'DDDo Oct 1 - 31, 2024' );
+ } );
+
+ it( 'should escape no more of a backslashed day run than moment does', () => {
+ // Moment reads "\DDDDD" as an escaped "DDDD" and a live day of month
+ // token, so the fifth "D" still carries the range.
+ ( __ as jest.Mock ).mockReturnValueOnce( '\\DDDDD MMM YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'DDDD1 - 31 Oct 2024' );
+ } );
+
+ it( 'should render a weekday from the start of the range', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( 'ddd, MMM D, YYYY' );
+
+ // Oct 1 2024 is a Tuesday, Oct 31 a Thursday.
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'Tue, Oct 1 - 31, 2024' );
+ } );
+
+ it( 'should render a week number from the start of the range', () => {
+ ( __ as jest.Mock ).mockReturnValueOnce( 'MMM D, YYYY [w]w' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'Oct 1 - 31, 2024 w40' );
+ } );
+
+ describe( 'with a locale that inflects the month name', () => {
+ // Moment picks the genitive month name over the nominative one by
+ // testing the format string for a day token next to the month one, and
+ // locales disagree on how: some rely on moment's own regex, some ship a
+ // stricter one, and some replace the month names with a function that
+ // tests the format itself. Each is covered here because a format string
+ // that stops matching renders the wrong grammatical form.
+ const genitive = Array.from(
+ { length: 12 },
+ ( _, index ) => `month${ index + 1 }-genitive`
+ );
+ const nominative = Array.from(
+ { length: 12 },
+ ( _, index ) => `month${ index + 1 }-nominative`
+ );
+ let originalLocale: string;
+
+ beforeAll( () => {
+ originalLocale = moment.locale();
+ } );
+
+ afterEach( () => {
+ moment.locale( originalLocale );
+ } );
+
+ it( "should keep the genitive month name of moment's own format test", () => {
+ moment.defineLocale( 'inflected-months', {
+ months: { format: genitive, standalone: nominative },
+ monthsShort: { format: genitive, standalone: nominative },
+ } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'D MMMM YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( '1 - 31 month10-genitive 2024' );
+ } );
+
+ it( 'should keep the genitive month name of a locale that allows only whitespace before the month', () => {
+ // Mirrors the Catalan locale's stricter `isFormat`.
+ moment.defineLocale( 'inflected-months-strict', {
+ months: {
+ format: genitive,
+ standalone: nominative,
+ isFormat: /D[oD]?(\s)+MMMM/,
+ },
+ monthsShort: { format: genitive, standalone: nominative },
+ } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'D MMMM YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( '1 - 31 month10-genitive 2024' );
+ } );
+
+ it( 'should keep the genitive month name of a locale that tests the format itself', () => {
+ // Mirrors the Polish locale, which resolves month names in code.
+ moment.defineLocale( 'inflected-months-fn', {
+ months: ( monthMoment, format ) =>
+ ( /D MMMM/.test( format || '' ) ? genitive : nominative )[
+ monthMoment.month()
+ ],
+ monthsShort: { format: genitive, standalone: nominative },
+ } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'D MMMM YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( '1 - 31 month10-genitive 2024' );
+ } );
+
+ it( 'should keep the genitive short month name of a "MMM" format', () => {
+ const shortGenitive = Array.from(
+ { length: 12 },
+ ( _, index ) => `short${ index + 1 }-genitive`
+ );
+ const shortNominative = Array.from(
+ { length: 12 },
+ ( _, index ) => `short${ index + 1 }-nominative`
+ );
+ moment.defineLocale( 'inflected-months-abbreviated', {
+ months: { format: genitive, standalone: nominative },
+ monthsShort: {
+ format: shortGenitive,
+ standalone: shortNominative,
+ },
+ } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'D MMM YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( '1 - 31 short10-genitive 2024' );
+ } );
+
+ it( 'should keep the genitive month name behind a localized format token', () => {
+ // The WOOAIRR-105 shape itself: the translation resolves to a
+ // localized token, and only its expansion reveals the day sitting
+ // next to the month.
+ moment.defineLocale( 'inflected-months-localized', {
+ months: { format: genitive, standalone: nominative },
+ monthsShort: { format: genitive, standalone: nominative },
+ longDateFormat: {
+ LT: 'HH:mm',
+ LTS: 'HH:mm:ss',
+ L: 'DD/MM/YYYY',
+ LL: 'D MMMM YYYY',
+ LLL: 'D MMMM YYYY HH:mm',
+ LLLL: 'dddd, D MMMM YYYY HH:mm',
+ },
+ } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'LL' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( '1 - 31 month10-genitive 2024' );
+ } );
+
+ it( 'should keep the nominative month name when the format holds no day token', () => {
+ moment.defineLocale( 'inflected-months-standalone', {
+ months: { format: genitive, standalone: nominative },
+ monthsShort: { format: genitive, standalone: nominative },
+ } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'MMMM YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'month10-nominative 2024' );
+ } );
+ } );
+
+ describe( 'with a locale that inflects the weekday name', () => {
+ // Mirrors the Ukrainian locale, which renders the genitive weekday
+ // whenever a bracketed literal precedes "dddd" - exactly the shape the
+ // month and day substitutions leave behind.
+ const weekdayGenitive = Array.from(
+ { length: 7 },
+ ( _, index ) => `weekday${ index }-genitive`
+ );
+ const weekdayNominative = Array.from(
+ { length: 7 },
+ ( _, index ) => `weekday${ index }-nominative`
+ );
+ const weekdays = ( dayMoment: moment.Moment, format?: string ) =>
+ ( /\] ?dddd/.test( format || '' )
+ ? weekdayGenitive
+ : weekdayNominative )[ dayMoment.day() ];
+ let originalLocale: string;
+
+ beforeAll( () => {
+ originalLocale = moment.locale();
+ } );
+
+ afterEach( () => {
+ moment.locale( originalLocale );
+ } );
+
+ it( 'should keep the nominative weekday after the month name', () => {
+ moment.defineLocale( 'inflected-weekdays', { weekdays } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'MMM dddd D YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'Oct weekday2-nominative 1 - 31 2024' );
+ } );
+
+ it( 'should keep the nominative weekday after the day of month', () => {
+ moment.defineLocale( 'inflected-weekdays-after-day', { weekdays } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'D dddd MMM YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( '1 - 31 weekday2-nominative Oct 2024' );
+ } );
+
+ it( 'should keep the genitive weekday of a format that asks for it', () => {
+ moment.defineLocale( 'inflected-weekdays-literal', { weekdays } );
+ ( __ as jest.Mock ).mockReturnValueOnce( '[у] dddd, MMM D, YYYY' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'у weekday2-genitive, Oct 1 - 31, 2024' );
+ } );
+ } );
+
+ describe( 'with a locale that nests localized format tokens', () => {
+ // `loadLocaleData` below builds "LLL" from a translation that still
+ // holds "LT", so on a real site an expansion can itself hold a
+ // localized token and a single pass is not enough.
+ let originalLocale: string;
+
+ beforeAll( () => {
+ originalLocale = moment.locale();
+ } );
+
+ afterEach( () => {
+ moment.locale( originalLocale );
+ } );
+
+ it( 'should expand a localized format token that expands to another', () => {
+ moment.defineLocale( 'nested-long-formats', {
+ longDateFormat: {
+ LT: 'HH:mm',
+ LTS: 'HH:mm:ss',
+ L: 'MM/DD/YYYY',
+ LL: 'LLL',
+ LLL: 'MMMM D, YYYY',
+ LLLL: 'dddd, MMMM D, YYYY',
+ },
+ } );
+ ( __ as jest.Mock ).mockReturnValueOnce( 'LL' );
+
+ expect(
+ getRangeLabel( moment( '2024-10-01' ), moment( '2024-10-31' ) )
+ ).toBe( 'October 1 - 31, 2024' );
+ } );
+ } );
} );
describe( 'loadLocaleData', () => {