/** * External dependencies */ import type { Moment } from 'moment'; import momentLib from 'moment'; import 'moment-timezone/moment-timezone.js'; import 'moment-timezone/moment-timezone-utils.js'; /** * WordPress dependencies */ import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ import type { DateSettings } from './types'; export type * from './types'; const WP_ZONE = 'WP'; // This regular expression tests positive for UTC offsets as described in ISO 8601. // See: https://en.wikipedia.org/wiki/ISO_8601#Time_offsets_from_UTC const VALID_UTC_OFFSET = /^[+-][0-1][0-9](:?[0-9][0-9])?$/; // Changes made here will likely need to be synced with Core in the file // src/wp-includes/script-loader.php in `wp_default_packages_inline_scripts()`. let settings: DateSettings = { l10n: { locale: 'en', months: [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ], monthsShort: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ], weekdays: [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', ], weekdaysShort: [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ], meridiem: { am: 'am', pm: 'pm', AM: 'AM', PM: 'PM' }, relative: { future: '%s from now', past: '%s ago', s: 'a few seconds', ss: '%d seconds', m: 'a minute', mm: '%d minutes', h: 'an hour', hh: '%d hours', d: 'a day', dd: '%d days', M: 'a month', MM: '%d months', y: 'a year', yy: '%d years', }, startOfWeek: 0, }, formats: { time: 'g:i a', date: 'F j, Y', datetime: 'F j, Y g:i a', datetimeAbbreviated: 'M j, Y g:i a', }, timezone: { offset: 0, offsetFormatted: '0', string: '', abbr: '' }, }; /** * Adds a locale to moment, using the format supplied by `wp_localize_script()`. * * @param dateSettings Settings, including locale data. */ export function setSettings( dateSettings: DateSettings ) { settings = dateSettings; setupWPTimezone(); // Does moment already have a locale with the right name? if ( momentLib.locales().includes( dateSettings.l10n.locale ) ) { // Is that locale misconfigured, e.g. because we are on a site running // WordPress < 6.0? if ( momentLib .localeData( dateSettings.l10n.locale ) .longDateFormat( 'LTS' ) === null ) { // Delete the misconfigured locale. // @ts-ignore Type definitions are incorrect - null is permitted. momentLib.defineLocale( dateSettings.l10n.locale, null ); } else { // We have a properly configured locale, so no need to create one. return; } } // defineLocale() will modify the current locale, so back it up. const currentLocale = momentLib.locale(); // Create locale. momentLib.defineLocale( dateSettings.l10n.locale, { // Inherit anything missing from English. We don't load // moment-with-locales.js so English is all there is. parentLocale: 'en', months: dateSettings.l10n.months, monthsShort: dateSettings.l10n.monthsShort, weekdays: dateSettings.l10n.weekdays, weekdaysShort: dateSettings.l10n.weekdaysShort, meridiem( hour, minute, isLowercase ) { if ( hour < 12 ) { return isLowercase ? dateSettings.l10n.meridiem.am : dateSettings.l10n.meridiem.AM; } return isLowercase ? dateSettings.l10n.meridiem.pm : dateSettings.l10n.meridiem.PM; }, longDateFormat: { LT: dateSettings.formats.time, LTS: momentLib.localeData( 'en' ).longDateFormat( 'LTS' ), L: momentLib.localeData( 'en' ).longDateFormat( 'L' ), LL: dateSettings.formats.date, LLL: dateSettings.formats.datetime, LLLL: momentLib.localeData( 'en' ).longDateFormat( 'LLLL' ), }, // From human_time_diff? // Set to `(number, withoutSuffix, key, isFuture) => {}` instead. relativeTime: dateSettings.l10n.relative, } ); // Restore the locale to what it was. momentLib.locale( currentLocale ); } /** * Returns the currently defined date settings. * * @return {DateSettings} Settings, including locale data. */ export function getSettings() { return settings; } /** * Returns the currently defined date settings. * * @deprecated * @return {DateSettings} Settings, including locale data. */ export function __experimentalGetSettings() { deprecated( 'wp.date.__experimentalGetSettings', { since: '6.1', alternative: 'wp.date.getSettings', } ); return getSettings(); } // Cached packed zone string, used to re-add the WP zone without requiring // moment-timezone-utils (which provides .pack()) after a third-party plugin // reloads moment-timezone and destroys both the zone and the utils. let wpZonePacked: string | undefined; /** * Ensures the custom WP timezone zone exists in moment-timezone's registry. * * Third-party plugins (e.g. WooCommerce) may load their own copy of * moment-timezone, which reinitializes the internal zone storage and * destroys the custom 'WP' zone. This function checks for the zone's * existence and re-creates it if necessary. */ function ensureWPTimezone() { if ( ! momentLib.tz.zone( WP_ZONE ) ) { if ( wpZonePacked ) { momentLib.tz.add( wpZonePacked ); } else { setupWPTimezone(); } } } function setupWPTimezone() { // Get the current timezone settings from the WP timezone string. const currentTimezone = momentLib.tz.zone( settings.timezone.string ); let packed; // Check to see if we have a valid TZ data, if so, use it for the custom WP_ZONE timezone, otherwise just use the offset. if ( currentTimezone ) { // Create WP timezone based off settings.timezone.string. We need to include the additional data so that we // don't lose information about daylight savings time and other items. // See https://github.com/WordPress/gutenberg/pull/48083 packed = momentLib.tz.pack( { name: WP_ZONE, abbrs: currentTimezone.abbrs, untils: currentTimezone.untils, offsets: currentTimezone.offsets, } ); } else { // Create WP timezone based off dateSettings. packed = momentLib.tz.pack( { name: WP_ZONE, abbrs: [ WP_ZONE ], untils: [ null ], offsets: [ -settings.timezone.offset * 60 || 0 ], } ); } wpZonePacked = packed; momentLib.tz.add( packed ); } // Date constants. /** * Number of seconds in one minute. */ const MINUTE_IN_SECONDS = 60; /** * Number of minutes in one hour. */ const HOUR_IN_MINUTES = 60; /** * Number of seconds in one hour. */ const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS; /** * Map of PHP formats to Moment.js formats. * * These are used internally by {@link format}, and are either * a string representing the corresponding Moment.js format code, or a * function which returns the formatted string. * * This should only be used through {@link format}, not * directly. */ const formatMap = { // Day. d: 'DD', D: 'ddd', j: 'D', l: 'dddd', N: 'E', /** * Gets the ordinal suffix. * * @param momentDate Moment instance. * * @return Formatted date. */ S( momentDate: Moment ) { // Do - D. const num = momentDate.format( 'D' ); const withOrdinal = momentDate.format( 'Do' ); return withOrdinal.replace( num, '' ); }, w: 'd', /** * Gets the day of the year (zero-indexed). * * @param momentDate Moment instance. * * @return Formatted date. */ z( momentDate: Moment ) { // DDD - 1. return ( parseInt( momentDate.format( 'DDD' ), 10 ) - 1 ).toString(); }, // Week. W: 'W', // Month. F: 'MMMM', m: 'MM', M: 'MMM', n: 'M', /** * Gets the days in the month. * * @param momentDate Moment instance. * * @return Formatted date. */ t( momentDate: Moment ) { return momentDate.daysInMonth(); }, // Year. /** * Gets whether the current year is a leap year. * * @param momentDate Moment instance. * * @return Formatted date. */ L( momentDate: Moment ) { return momentDate.isLeapYear() ? '1' : '0'; }, o: 'GGGG', Y: 'YYYY', y: 'YY', // Time. a: 'a', A: 'A', /** * Gets the current time in Swatch Internet Time (.beats). * * @param momentDate Moment instance. * * @return Formatted date. */ B( momentDate: Moment ) { const timezoned = momentLib( momentDate ).utcOffset( 60 ); const seconds = parseInt( timezoned.format( 's' ), 10 ), minutes = parseInt( timezoned.format( 'm' ), 10 ), hours = parseInt( timezoned.format( 'H' ), 10 ); return parseInt( ( ( seconds + minutes * MINUTE_IN_SECONDS + hours * HOUR_IN_SECONDS ) / 86.4 ).toString(), 10 ); }, g: 'h', G: 'H', h: 'hh', H: 'HH', i: 'mm', s: 'ss', u: 'SSSSSS', v: 'SSS', // Timezone. e: 'zz', /** * Gets whether the timezone is in DST currently. * * @param momentDate Moment instance. * * @return Formatted date. */ I( momentDate: Moment ) { return momentDate.isDST() ? '1' : '0'; }, O: 'ZZ', P: 'Z', T: 'z', /** * Gets the timezone offset in seconds. * * @param momentDate Moment instance. * * @return Formatted date. */ Z( momentDate: Moment ) { // Timezone offset in seconds. const offset = momentDate.format( 'Z' ); const sign = offset[ 0 ] === '-' ? -1 : 1; const parts = offset .substring( 1 ) .split( ':' ) .map( ( n ) => parseInt( n, 10 ) ); return ( sign * ( parts[ 0 ] * HOUR_IN_MINUTES + parts[ 1 ] ) * MINUTE_IN_SECONDS ); }, // Full date/time. c: 'YYYY-MM-DDTHH:mm:ssZ', // .toISOString. /** * Formats the date as RFC2822. * * @param momentDate Moment instance. * * @return Formatted date. */ r( momentDate: Moment ) { return momentDate .locale( 'en' ) .format( 'ddd, DD MMM YYYY HH:mm:ss ZZ' ); }, U: 'X', }; /** * Formats a date. Does not alter the date's timezone. * * @param dateFormat PHP-style formatting string. * See [php.net/date](https://www.php.net/manual/en/function.date.php). * @param dateValue Date object or string, * parsable by moment.js. * * @return Formatted date. */ export function format( dateFormat: string, dateValue: Moment | Date | string | number = new Date() ) { let i, char; const newFormat = []; const momentDate = momentLib( dateValue ); for ( i = 0; i < dateFormat.length; i++ ) { char = dateFormat[ i ]; // Is this an escape? if ( '\\' === char ) { // Add next character, then move on. i++; newFormat.push( '[' + dateFormat[ i ] + ']' ); continue; } if ( char in formatMap ) { const formatter = formatMap[ char as keyof typeof formatMap ]; if ( typeof formatter !== 'string' ) { // If the format is a function, call it. newFormat.push( '[' + formatter( momentDate ) + ']' ); } else { // Otherwise, add as a formatting string. newFormat.push( formatter ); } } else { newFormat.push( '[' + char + ']' ); } } // Join with [] between to separate characters, and replace // unneeded separators with static text. return momentDate.format( newFormat.join( '[]' ) ); } /** * Formats a date (like `date()` in PHP). * * @param dateFormat PHP-style formatting string. * See [php.net/date](https://www.php.net/manual/en/function.date.php). * @param dateValue Date object or string, parsable * by moment.js. * @param timezone Timezone to output result in or a * UTC offset. Defaults to timezone from * site. * * @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones * @see https://en.wikipedia.org/wiki/ISO_8601#Time_offsets_from_UTC * * @return {string} Formatted date in English. */ export function date( dateFormat: string, dateValue: Moment | Date | string | number = new Date(), timezone?: string ) { const dateMoment = buildMoment( dateValue, timezone ); return format( dateFormat, dateMoment ); } /** * Formats a date (like `date()` in PHP), in the UTC timezone. * * @param dateFormat PHP-style formatting string. * See [php.net/date](https://www.php.net/manual/en/function.date.php). * @param dateValue Date object or string, * parsable by moment.js. * * @return Formatted date in English. */ export function gmdate( dateFormat: string, dateValue: Moment | Date | string | number = new Date() ) { const dateMoment = momentLib( dateValue ).utc(); return format( dateFormat, dateMoment ); } /** * Formats a date (like `wp_date()` in PHP), translating it into site's locale. * * Backward Compatibility Notice: if `timezone` is set to `true`, the function * behaves like `gmdateI18n`. * * @param dateFormat PHP-style formatting string. * See [php.net/date](https://www.php.net/manual/en/function.date.php). * @param dateValue Date object or string, parsable by * moment.js. * @param timezone Timezone to output result in or a * UTC offset. Defaults to timezone from * site. Notice: `boolean` is effectively * deprecated, but still supported for * backward compatibility reasons. * * @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones * @see https://en.wikipedia.org/wiki/ISO_8601#Time_offsets_from_UTC * * @return Formatted date. */ export function dateI18n( dateFormat: string, dateValue: Moment | Date | string | number = new Date(), timezone?: string | number | boolean ) { if ( true === timezone ) { return gmdateI18n( dateFormat, dateValue ); } if ( false === timezone ) { timezone = undefined; } const dateMoment = buildMoment( dateValue, timezone ); dateMoment.locale( settings.l10n.locale ); return format( dateFormat, dateMoment ); } /** * Formats a date (like `wp_date()` in PHP), translating it into site's locale * and using the UTC timezone. * * @param dateFormat PHP-style formatting string. * See [php.net/date](https://www.php.net/manual/en/function.date.php). * @param dateValue Date object or string, * parsable by moment.js. * * @return Formatted date. */ export function gmdateI18n( dateFormat: string, dateValue: Moment | Date | string | number = new Date() ) { const dateMoment = momentLib( dateValue ).utc(); dateMoment.locale( settings.l10n.locale ); return format( dateFormat, dateMoment ); } /** * Check whether a date is considered in the future according to the WordPress settings. * * @param dateValue Date String or Date object in the Defined WP Timezone. * * @return Is in the future. */ export function isInTheFuture( dateValue: Date | string | number ) { ensureWPTimezone(); const now = momentLib.tz( WP_ZONE ); const momentObject = momentLib.tz( dateValue, WP_ZONE ); return momentObject.isAfter( now ); } /** * Create and return a JavaScript Date Object from a date string in the WP timezone. * * @param dateString Date formatted in the WP timezone. * * @return Date */ export function getDate( dateString?: string | null ) { ensureWPTimezone(); if ( ! dateString ) { return momentLib.tz( WP_ZONE ).toDate(); } return momentLib.tz( dateString, WP_ZONE ).toDate(); } /** * Returns a human-readable time difference between two dates, like human_time_diff() in PHP. * * @param from From date, in the WP timezone. * @param to To date, formatted in the WP timezone. * * @return Human-readable time difference. */ export function humanTimeDiff( from: Moment | Date | string | number, to?: Moment | Date | string | number ) { ensureWPTimezone(); const fromMoment = momentLib.tz( from, WP_ZONE ); const toMoment = to ? momentLib.tz( to, WP_ZONE ) : momentLib.tz( WP_ZONE ); return fromMoment.from( toMoment ); } /** * Creates a moment instance using the given timezone or, if none is provided, using global settings. * * @param dateValue Date object or string, parsable * by moment.js. * @param timezone Timezone to output result in or a * UTC offset. Defaults to timezone from * site. * * @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones * @see https://en.wikipedia.org/wiki/ISO_8601#Time_offsets_from_UTC * * @return A moment instance. */ function buildMoment( dateValue?: Moment | Date | string | number, timezone: string | number = '' ) { const dateMoment = momentLib( dateValue ); if ( timezone !== '' ) { return isUTCOffset( timezone ) ? dateMoment.utcOffset( timezone ) : // A false isUTCOffset() guarantees that timezone is a string. dateMoment.tz( timezone as string ); } if ( settings.timezone.string ) { return dateMoment.tz( settings.timezone.string ); } return dateMoment.utcOffset( +settings.timezone.offset ); } /** * Returns whether a certain UTC offset is valid or not. * * @param offset a UTC offset. * * @return whether a certain UTC offset is valid or not. */ function isUTCOffset( offset: number | string ) { if ( 'number' === typeof offset ) { return true; } return VALID_UTC_OFFSET.test( offset ); } setupWPTimezone();