PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Services / DateTime / DayjsFormatter.php

DayjsFormatter.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.6, at app/Services/DateTime/DayjsFormatter.php

339 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\DateTime;
4
5 use FluentCart\App\Services\Translations\TransStrings;
6 use FluentCart\Framework\Support\Arr;
7
8 /**
9 * Converts the store's PHP date formats into Day.js patterns for the admin SPA
10 * and the customer dashboard.
11 *
12 * The SPA already receives translated month and weekday names, but the pattern
13 * itself was a literal inside the bundle, so a localized store ended up with
14 * translated names in English order -- "August 16, 2026" on a German site.
15 * Shipping the store's own format alongside the names is what fixes that.
16 *
17 * DateFormatter owns the PHP side; this class is only the bridge to Day.js.
18 */
19 class DayjsFormatter
20 {
21 /**
22 * Every character PHP's date() treats as a format token.
23 *
24 * Used to tell tokens apart from literal separators when deriving one
25 * format from another, and to drop a token Day.js cannot express.
26 */
27 protected static $dateTokens = 'dDjlNSwzWFmMntLoXxYyaABgGhHisuveIOPpTZcrU';
28
29 /**
30 * PHP date() token => Day.js token.
31 *
32 * A PHP token absent from this map has no Day.js equivalent and is dropped.
33 * 'S' (ordinal suffix) is handled separately because Day.js folds it into
34 * the day token itself ('jS' => 'Do').
35 */
36 protected static $tokenMap = [
37 // Day
38 'd' => 'DD',
39 'j' => 'D',
40 'D' => 'ddd',
41 'l' => 'dddd',
42 'N' => 'd',
43 'w' => 'd',
44 'z' => 'DDD',
45 // Week
46 'W' => 'w',
47 // Month
48 'F' => 'MMMM',
49 'm' => 'MM',
50 'M' => 'MMM',
51 'n' => 'M',
52 // Year
53 'o' => 'YYYY',
54 'X' => 'YYYY',
55 'x' => 'YYYY',
56 'Y' => 'YYYY',
57 'y' => 'YY',
58 // Time
59 'a' => 'a',
60 'A' => 'A',
61 'g' => 'h',
62 'G' => 'H',
63 'h' => 'hh',
64 'H' => 'HH',
65 'i' => 'mm',
66 's' => 'ss',
67 'u' => 'SSS',
68 'v' => 'SSS',
69 // Timezone
70 'e' => 'z',
71 'O' => 'ZZ',
72 'P' => 'Z',
73 'p' => 'Z',
74 'T' => 'z',
75 // Full date/time
76 'c' => 'YYYY-MM-DDTHH:mm:ssZ',
77 'r' => 'ddd, DD MMM YYYY HH:mm:ss ZZ',
78 'U' => 'X',
79 ];
80
81 /**
82 * The date/time map for the SPA payload, with Day.js patterns.
83 *
84 * Same map PHP reads, except the formats are converted from PHP date()
85 * syntax to Day.js tokens. Localize this rather than the raw map, or the
86 * bundle would receive patterns it cannot parse.
87 *
88 * @return array<string, mixed>
89 */
90 public static function localizedStrings(): array
91 {
92 $strings = TransStrings::dateTimeStrings();
93
94 $strings['formats'] = static::formats();
95
96 return $strings;
97 }
98
99 /**
100 * The store's formats as Day.js patterns, keyed for the JS helpers.
101 *
102 * date_short and month_year have no WordPress setting of their own; they
103 * are derived from the store's date format so that a compact table column
104 * or a chart label keeps the locale's own field order.
105 *
106 * @return array<string, string>
107 */
108 public static function formats(): array
109 {
110 $formats = DateFormatter::formats();
111
112 $date = (string)Arr::get($formats, 'date', DateFormatter::FALLBACK_DATE);
113 $time = (string)Arr::get($formats, 'time', DateFormatter::FALLBACK_TIME);
114 $dateTime = (string)Arr::get($formats, 'date_time', $date . ' ' . $time);
115
116 return [
117 'date' => static::convert($date),
118 'date_short' => static::convert(static::abbreviateMonth(static::withoutYear($date))),
119 'time' => static::convert($time),
120 'date_time' => static::convert($dateTime),
121 'month_year' => static::convert(static::withoutDay($date)),
122 'month_long' => 'MMMM',
123 ];
124 }
125
126 /**
127 * Convert a PHP date() format string to its Day.js equivalent.
128 *
129 * Characters that are not PHP tokens are emitted as Day.js literals, so a
130 * stray letter in a translated format is not read back as a token.
131 */
132 public static function convert(string $format): string
133 {
134 $out = '';
135 $literals = '';
136 $length = strlen($format);
137
138 for ($i = 0; $i < $length; $i++) {
139 $char = $format[$i];
140
141 // A backslash escapes the next character in PHP date formats.
142 if ($char === '\\') {
143 if ($i + 1 < $length) {
144 $i++;
145 $literals .= $format[$i];
146 }
147 continue;
148 }
149
150 // 'S' renders the ordinal suffix of the preceding day token.
151 // Day.js has no separate suffix token, so fold 'D'/'DD' into 'Do'.
152 if ($char === 'S') {
153 if (substr($out, -2) === 'DD') {
154 $out = substr($out, 0, -2) . 'Do';
155 } elseif (substr($out, -1) === 'D') {
156 $out = substr($out, 0, -1) . 'Do';
157 }
158 continue;
159 }
160
161 if (isset(static::$tokenMap[$char])) {
162 if ($literals !== '') {
163 $out .= '[' . $literals . ']';
164 $literals = '';
165 }
166 $out .= static::$tokenMap[$char];
167 continue;
168 }
169
170 // A PHP token Day.js cannot express is dropped rather than emitted
171 // as a bare letter, which Day.js would read as some other token.
172 if (strpos(static::$dateTokens, $char) !== false) {
173 continue;
174 }
175
176 // Non-token letters must be escaped or Day.js would read them as tokens.
177 if (preg_match('/[A-Za-z]/', $char)) {
178 $literals .= $char;
179 continue;
180 }
181
182 if ($literals !== '') {
183 $out .= '[' . $literals . ']';
184 $literals = '';
185 }
186 $out .= $char;
187 }
188
189 if ($literals !== '') {
190 $out .= '[' . $literals . ']';
191 }
192
193 return $out;
194 }
195
196 /**
197 * Drop the year from a PHP date format, keeping the locale's day/month order.
198 *
199 * Used for compact table columns that omit the year for the current year.
200 *
201 * Public because this takes and returns a PHP date format, not a Day.js one
202 * -- DateFormatter derives its own month-year pattern from the same helper
203 * so the e-mail digest and the charts agree on the locale's field order.
204 */
205 public static function withoutYear(string $format): string
206 {
207 return static::dropTokens($format, ['Y', 'y', 'o', 'X', 'x']);
208 }
209
210 /**
211 * Drop the day from a PHP date format, leaving month and year.
212 */
213 public static function withoutDay(string $format): string
214 {
215 return static::dropTokens($format, ['d', 'j', 'D', 'l', 'N', 'w', 'z', 'S']);
216 }
217
218 /**
219 * Replace the full month-name token with the abbreviated one.
220 *
221 * date_short is for compact table columns, where a German store's
222 * "16. August" is wider than the column needs. Swapping the PHP token
223 * BEFORE convert() keeps this generic across locales: 'F' becomes 'M',
224 * which convert()'s existing token map already renders as Day.js 'MMM'
225 * (the store's own translated monthsShort), the same way it always has
226 * for a store whose date format already spells the month short. A
227 * numeric month token ('n', 'm') is left untouched -- a store that
228 * writes the month as a digit should stay numeric, not gain a name.
229 */
230 public static function abbreviateMonth(string $format): string
231 {
232 $out = '';
233 $length = strlen($format);
234
235 for ($i = 0; $i < $length; $i++) {
236 $char = $format[$i];
237
238 // A backslash escapes the next character; never touch it, or an
239 // intentional literal "F" would be shortened like a real token.
240 if ($char === '\\') {
241 if ($i + 1 < $length) {
242 $out .= $char . $format[$i + 1];
243 $i++;
244 }
245 continue;
246 }
247
248 $out .= $char === 'F' ? 'M' : $char;
249 }
250
251 return $out;
252 }
253
254 /**
255 * Remove the given tokens, plus the separator their removal left stranded.
256 *
257 * The format is split into token and literal parts first. When a token is
258 * dropped, the separator that FOLLOWS it goes too -- or the one that
259 * precedes it, when the dropped token was last. That keeps suffix-style
260 * locales such as the Japanese 'Y\u5e74n\u6708j\u65e5' intact instead of
261 * shaving off their trailing marker.
262 *
263 * @param string[] $tokens
264 */
265 protected static function dropTokens(string $format, array $tokens): string
266 {
267 $parts = [];
268 $literal = '';
269 $length = strlen($format);
270
271 for ($i = 0; $i < $length; $i++) {
272 $char = $format[$i];
273
274 // A backslash escapes the next character; it is never a token.
275 if ($char === '\\') {
276 if ($i + 1 < $length) {
277 $literal .= $char . $format[$i + 1];
278 $i++;
279 }
280 continue;
281 }
282
283 $isToken = strpos(static::$dateTokens, $char) !== false;
284
285 if (!$isToken) {
286 $literal .= $char;
287 continue;
288 }
289
290 if ($literal !== '') {
291 $parts[] = ['type' => 'literal', 'value' => $literal, 'drop' => false];
292 $literal = '';
293 }
294
295 $parts[] = [
296 'type' => 'token',
297 'value' => $char,
298 'drop' => in_array($char, $tokens, true),
299 ];
300 }
301
302 if ($literal !== '') {
303 $parts[] = ['type' => 'literal', 'value' => $literal, 'drop' => false];
304 }
305
306 $total = count($parts);
307
308 foreach ($parts as $index => $part) {
309 if ($part['type'] !== 'token' || !$part['drop']) {
310 continue;
311 }
312
313 // Prefer the separator after the dropped token, so that a suffix
314 // marker leaves with the token it belonged to.
315 if ($index + 1 < $total && $parts[$index + 1]['type'] === 'literal') {
316 $parts[$index + 1]['drop'] = true;
317 continue;
318 }
319
320 // The dropped token was last: take the separator in front of it.
321 if ($index + 1 >= $total && $index > 0 && $parts[$index - 1]['type'] === 'literal') {
322 $parts[$index - 1]['drop'] = true;
323 }
324 }
325
326 $out = '';
327
328 foreach ($parts as $part) {
329 if (!$part['drop']) {
330 $out .= $part['value'];
331 }
332 }
333
334 $out = trim($out);
335
336 return $out === '' ? $format : $out;
337 }
338 }
339