name ) || isset( $this->zone ) || isset( $this->fallback ) ) { throw new \Error( 'A captured timezone cannot be initialized twice.' ); } $this->name = $name; $this->zone = new \DateTimeZone( $name ); $this->fallback = $fallback; } /** * Validate a finite zone name, including WordPress's fixed-offset fallback. * * @param string $name Named timezone or canonical offset bounded to 64 bytes. * @return self * @throws \InvalidArgumentException When the timezone is unsupported. */ public static function for_zone( string $name ): self { if ( '' === $name || strlen( $name ) > 64 ) { throw new \InvalidArgumentException( 'Use a bounded named timezone or canonical UTC offset.' ); } static $named = null; if ( null === $named ) { $named = array_fill_keys( \DateTimeZone::listIdentifiers( \DateTimeZone::ALL_WITH_BC ), true ); } $offset = false; if ( preg_match( '/\A([+-])(\d{2}):(\d{2})\z/D', $name, $parts ) ) { $hours = (int) $parts[2]; $minutes = (int) $parts[3]; $offset = $hours <= 14 && $minutes <= 59 && ( 14 !== $hours || 0 === $minutes ) && '-00:00' !== $name; } if ( ! isset( $named[ $name ] ) && ! $offset ) { throw new \InvalidArgumentException( 'Use a named timezone or canonical UTC offset.' ); } return new self( $name ); } /** * Callback-bearing snapshot: call before protected reads/rendering and final * authority seals. This helper selects no scope and grants no data access. * * @return self A captured presentation zone, with an explicit UTC fallback. * @throws \RuntimeException When native context is unavailable or changes. * @phpstan-impure */ public static function site(): self { global $wpdb; /** * Current native database context, checked before timezone callbacks. * * @var mixed $wpdb */ if ( ! $wpdb instanceof \wpdb || ! class_exists( Derived::class, false ) ) { throw new \RuntimeException( 'The site timezone context is unavailable.' ); } $db = $wpdb; $origin = Derived::native_context( $db ); try { $name = wp_timezone_string(); } catch ( \Throwable $error ) { $name = null; } if ( Derived::native_context( $db ) !== $origin ) { throw new \RuntimeException( 'The site timezone context changed.' ); } try { return is_string( $name ) ? self::for_zone( $name ) : new self( 'UTC', true ); } catch ( \InvalidArgumentException $error ) { return new self( 'UTC', true ); } } /** * Describe the selected zone, including an explicit configuration fallback. * * @return string Plain text for the rendering controller to escape. */ public function zone(): string { return $this->name . ( $this->fallback ? ' (site timezone unavailable)' : '' ); } /** * Return the validated timezone identifier without presentation labels. * * @return string */ public function name(): string { return $this->name; } /** * Render plain text for escaping inside the controller's existing output seal. * * @param mixed $value Canonical UTC string or integer epoch seconds. * @return string Local timestamp and offset, or an explicit unavailable label. */ public function text( mixed $value ): string { return $this->display( $value )['text'] ?? 'Time unavailable'; } /** * Produce a canonical UTC HTML datetime value without changing ordering keys. * * @param mixed $value Canonical UTC string or integer epoch seconds. * @return string|null Canonical UTC datetime, or null for invalid input. */ public function datetime( mixed $value ): ?string { return $this->display( $value )['datetime'] ?? null; } /** * Normalize supported timestamp representations for presentation only. * * @param mixed $value Canonical UTC string or integer epoch seconds. * @return array|null Presentation fields, or null for invalid input. */ private function display( mixed $value ): ?array { try { if ( is_int( $value ) ) { return $this->timestamp( $value ); } if ( ! is_string( $value ) ) { return null; } return $this->instant( 19 === strlen( $value ) ? $value . '.000000' : $value ); } catch ( \InvalidArgumentException $error ) { return null; } } /** * Format a canonical stored UTC instant without callbacks or HTML output. * * @param string $utc UTC timestamp with exactly six fractional digits. * @return array UTC, HTML datetime, plain local text, timezone and offset fields. * @throws \InvalidArgumentException When the stored instant is invalid. */ public function instant( string $utc ): array { if ( 26 !== strlen( $utc ) || ! preg_match( '/\A[1-9][0-9]{3}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}\z/D', $utc ) ) { throw new \InvalidArgumentException( 'Use a canonical UTC timestamp with six fractional digits.' ); } $date = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s.u', $utc, new \DateTimeZone( 'UTC' ) ); if ( false === $date || $date->format( 'Y-m-d H:i:s.u' ) !== $utc ) { throw new \InvalidArgumentException( 'The UTC timestamp is invalid.' ); } $local = $date->setTimezone( $this->zone ); $seconds = $local->getOffset(); $magnitude = abs( $seconds ); $offset = sprintf( '%s%02d:%02d', $seconds < 0 ? '-' : '+', intdiv( $magnitude, 3600 ), intdiv( $magnitude % 3600, 60 ) ); if ( 0 !== $magnitude % 60 ) { $offset .= sprintf( ':%02d', $magnitude % 60 ); } return array( 'utc' => $utc, 'datetime' => $date->format( 'Y-m-d\TH:i:s.u\Z' ), 'text' => $local->format( 'Y-m-d H:i:s.u' ) . ' ' . $offset, 'timezone' => $this->name, 'offset' => $offset, ); } /** * Format seconds-based policy or health values, including pre-epoch instants. * * @param int $seconds Unix epoch seconds within the supported calendar range. * @return array UTC, HTML datetime, plain local text, timezone and offset fields. * @throws \InvalidArgumentException When the instant is outside that range. */ public function timestamp( int $seconds ): array { try { $date = ( new \DateTimeImmutable( '@' . $seconds ) )->setTimezone( new \DateTimeZone( 'UTC' ) ); return $this->instant( $date->format( 'Y-m-d H:i:s.u' ) ); } catch ( \Throwable $error ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The public message is fixed; $error is the chained Throwable argument, never emitted as HTML. throw new \InvalidArgumentException( 'The timestamp is outside the supported calendar range.', 0, $error ); } } }