Skip to content

Commit a36bde1

Browse files
authored
feat: improve tag support for parity with formatjs (#24)
1 parent 146a0d4 commit a36bde1

10 files changed

Lines changed: 451 additions & 6 deletions

File tree

src/Config.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,24 @@ class Config implements ConfigInterface
3434
private ?LocaleInterface $defaultLocale;
3535
private string $idInterpolatorPattern;
3636

37+
/**
38+
* @var array<string, callable(string):string>
39+
*/
40+
private array $defaultRichTextElements;
41+
42+
/**
43+
* @param array<string, callable(string):string> $defaultRichTextElements
44+
*/
3745
public function __construct(
3846
LocaleInterface $locale,
3947
?LocaleInterface $defaultLocale = null,
48+
array $defaultRichTextElements = [],
4049
string $idInterpolatorPattern = IdInterpolator::DEFAULT_ID_INTERPOLATION_PATTERN
4150
) {
4251
$this->locale = $locale;
4352
$this->defaultLocale = $defaultLocale;
4453
$this->idInterpolatorPattern = $idInterpolatorPattern;
54+
$this->defaultRichTextElements = $defaultRichTextElements;
4555
}
4656

4757
public function getDefaultLocale(): ?LocaleInterface
@@ -58,4 +68,12 @@ public function getLocale(): LocaleInterface
5868
{
5969
return $this->locale;
6070
}
71+
72+
/**
73+
* @return array<string, callable(string):string>
74+
*/
75+
public function getDefaultRichTextElements(): array
76+
{
77+
return $this->defaultRichTextElements;
78+
}
6179
}

src/ConfigInterface.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ interface ConfigInterface
3535
*/
3636
public function getDefaultLocale(): ?LocaleInterface;
3737

38+
/**
39+
* Returns a map of tag names to rich text formatting functions
40+
*
41+
* This is meant to provide a centralized way to format common tags such as
42+
* `<b>`, `<p>`, or enforcing a certain design system in the codebase
43+
* (e.g., standardized `<a>`, `<button>`, etc.).
44+
*
45+
* The functions should be a callable that accepts a single string parameter
46+
* and returns a string. For example:
47+
*
48+
* ```php
49+
* [
50+
* 'em' => fn (string $text): string => '<em class="bar">' . $text . '</em>',
51+
* 'strong' => fn (string $text): string => '<strong class="foo">' . $text . '</strong>',
52+
* ]
53+
* ```
54+
*
55+
* @return array<string, callable(string):string>
56+
*/
57+
public function getDefaultRichTextElements(): array;
58+
3859
/**
3960
* Returns a pattern that defines how to generate missing message IDs
4061
*

src/FormatPHP.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use FormatPHP\Util\MessageCleaner;
2929
use FormatPHP\Util\MessageRetriever;
3030

31+
use function array_merge;
3132
use function is_int;
3233

3334
/**
@@ -62,6 +63,10 @@ public function __construct(
6263
*/
6364
public function formatMessage(array $descriptor, array $values = []): string
6465
{
66+
// Combine the global default rich text element callbacks with the values,
67+
// giving preference to values provided with the same keys.
68+
$values = array_merge($this->config->getDefaultRichTextElements(), $values);
69+
6570
try {
6671
$messagePattern = $this->getMessageForDescriptor(
6772
$this->messages,

src/Icu/MessageFormat/Parser/Error.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424

2525
use FormatPHP\Icu\MessageFormat\Parser\Type\Location;
2626

27+
/**
28+
* @psalm-type ErrorKind = Error::*
29+
*/
2730
class Error
2831
{
2932
/**
@@ -166,15 +169,15 @@ class Error
166169
public const UNCLOSED_TAG = 27;
167170

168171
/**
169-
* @psalm-var Error::*
172+
* @var ErrorKind
170173
*/
171174
public int $kind;
172175

173176
public string $message;
174177
public Location $location;
175178

176179
/**
177-
* @psalm-param Error::* $kind
180+
* @param ErrorKind $kind
178181
*/
179182
public function __construct(int $kind, string $message, Location $location)
180183
{
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/**
4+
* This file is part of skillshare/formatphp
5+
*
6+
* skillshare/formatphp is open source software: you can distribute
7+
* it and/or modify it under the terms of the MIT License
8+
* (the "License"). You may not use this file except in
9+
* compliance with the License.
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14+
* implied. See the License for the specific language governing
15+
* permissions and limitations under the License.
16+
*
17+
* @copyright Copyright (c) Skillshare, Inc. <https://www.skillshare.com>
18+
* @license https://opensource.org/licenses/MIT MIT License
19+
*/
20+
21+
declare(strict_types=1);
22+
23+
namespace FormatPHP\Icu\MessageFormat\Parser\Exception;
24+
25+
use FormatPHP\Icu\MessageFormat\Parser\Error;
26+
use ReflectionObject;
27+
use RuntimeException as PhpRuntimeException;
28+
use Throwable;
29+
30+
use function array_flip;
31+
use function sprintf;
32+
33+
/**
34+
* Thrown with a message format parser Error to indicate a syntax error
35+
* encountered while parsing a message
36+
*/
37+
class UnableToParseMessageException extends PhpRuntimeException implements ParserExceptionInterface
38+
{
39+
public function __construct(Error $error, ?Throwable $previous = null)
40+
{
41+
parent::__construct($this->createMessageForError($error), 0, $previous);
42+
}
43+
44+
private function createMessageForError(Error $error): string
45+
{
46+
return sprintf(
47+
'Syntax error %s found while parsing message "%s"',
48+
$this->getErrorTypeName($error),
49+
$error->message,
50+
);
51+
}
52+
53+
private function getErrorTypeName(Error $error): string
54+
{
55+
$reflection = new ReflectionObject($error);
56+
57+
// @phpstan-ignore-next-line
58+
$constants = array_flip($reflection->getConstants());
59+
60+
return $constants[$error->kind] ?? '';
61+
}
62+
}

src/Intl/MessageFormat.php

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,34 @@
2424

2525
use FormatPHP\Exception\InvalidArgumentException;
2626
use FormatPHP\Exception\UnableToFormatMessageException;
27-
use IntlException as PhpIntlException;
27+
use FormatPHP\Icu\MessageFormat\Parser;
28+
use FormatPHP\Icu\MessageFormat\Parser\Type\PluralElement;
29+
use FormatPHP\Icu\MessageFormat\Parser\Type\SelectElement;
30+
use FormatPHP\Icu\MessageFormat\Printer;
2831
use Locale as PhpLocale;
2932
use MessageFormatter as PhpMessageFormatter;
33+
use Ramsey\Collection\Exception\CollectionMismatchException;
34+
use Throwable;
3035

36+
use function array_filter;
37+
use function array_key_exists;
38+
use function array_keys;
39+
use function array_values;
40+
use function assert;
41+
use function is_callable;
3142
use function is_int;
43+
use function preg_match;
3244
use function sprintf;
3345

3446
/**
3547
* Formats an ICU message format pattern
3648
*/
3749
class MessageFormat implements MessageFormatInterface
3850
{
51+
private const CALLBACK_REPLACEMENT = '__FORMATPHP_CALLBACK_REPLACEMENT__';
52+
private const CALLBACK_RESULT_PATTERN = '/(.*)' . self::CALLBACK_REPLACEMENT . '(.*)/su';
53+
private const LITERAL_TAG_PATTERN = '/^<(.*)\/>$/su';
54+
3955
private LocaleInterface $locale;
4056

4157
/**
@@ -52,10 +68,11 @@ public function __construct(?LocaleInterface $locale = null)
5268
public function format(string $pattern, array $values = []): string
5369
{
5470
try {
71+
$pattern = $this->applyCallbacks($pattern, $values);
5572
$formatter = new PhpMessageFormatter((string) $this->locale->baseName(), $pattern);
5673

5774
return (string) $formatter->format($values);
58-
} catch (PhpIntlException $exception) {
75+
} catch (Throwable $exception) {
5976
throw new UnableToFormatMessageException(
6077
sprintf(
6178
'Unable to format message with pattern "%s" for locale "%s"',
@@ -67,4 +84,135 @@ public function format(string $pattern, array $values = []): string
6784
);
6885
}
6986
}
87+
88+
/**
89+
* @param array<array-key, float | int | string | callable(string):string> $values
90+
*
91+
* @throws Parser\Exception\IllegalParserUsageException
92+
* @throws Parser\Exception\InvalidArgumentException
93+
* @throws Parser\Exception\InvalidOffsetException
94+
* @throws Parser\Exception\InvalidSkeletonOption
95+
* @throws Parser\Exception\InvalidUtf8CodeBoundaryException
96+
* @throws Parser\Exception\InvalidUtf8CodePointException
97+
* @throws Parser\Exception\UnableToParseMessageException
98+
* @throws UnableToFormatMessageException
99+
* @throws CollectionMismatchException
100+
*/
101+
private function applyCallbacks(string $pattern, array &$values = []): string
102+
{
103+
$callbacks = array_filter($values, fn ($value): bool => is_callable($value));
104+
105+
// If $values doesn't contain any callables, go ahead and return.
106+
if (!$callbacks) {
107+
return $pattern;
108+
}
109+
110+
// Remove the callbacks from the values, since we will use them below.
111+
foreach (array_keys($callbacks) as $key) {
112+
unset($values[$key]);
113+
}
114+
115+
$parser = new Parser($pattern);
116+
$parsed = $parser->parse();
117+
118+
if ($parsed->err !== null) {
119+
throw new Parser\Exception\UnableToParseMessageException($parsed->err);
120+
}
121+
122+
assert($parsed->val instanceof Parser\Type\ElementCollection);
123+
124+
return (new Printer())->printAst($this->processAstWithCallbacks($parsed->val, $callbacks));
125+
}
126+
127+
/**
128+
* @param array<array-key, callable(string):string> $callbacks
129+
*
130+
* @throws CollectionMismatchException
131+
* @throws UnableToFormatMessageException
132+
*/
133+
private function processAstWithCallbacks(
134+
Parser\Type\ElementCollection $ast,
135+
array $callbacks
136+
): Parser\Type\ElementCollection {
137+
$processedAst = new Parser\Type\ElementCollection();
138+
139+
for ($i = 0; $i < $ast->count(); $i++) {
140+
$element = $ast[$i];
141+
assert($element instanceof Parser\Type\ElementInterface);
142+
$clone = clone $element;
143+
144+
if ($clone instanceof PluralElement || $clone instanceof SelectElement) {
145+
foreach ($clone->options as $option) {
146+
$option->value = $this->processAstWithCallbacks($option->value, $callbacks);
147+
}
148+
}
149+
150+
if ($clone instanceof Parser\Type\TagElement) {
151+
$processedAst = $processedAst->merge($this->processTagElement($clone, $callbacks));
152+
153+
continue;
154+
}
155+
156+
if ($clone instanceof Parser\Type\LiteralElement) {
157+
$clone = $this->processLiteralElement($clone, $callbacks);
158+
}
159+
160+
$processedAst[] = $clone;
161+
}
162+
163+
return $processedAst;
164+
}
165+
166+
/**
167+
* @param array<array-key, callable(string):string> $callbacks
168+
*
169+
* @throws CollectionMismatchException
170+
* @throws UnableToFormatMessageException
171+
*/
172+
private function processTagElement(
173+
Parser\Type\TagElement $tagElement,
174+
array $callbacks
175+
): Parser\Type\ElementCollection {
176+
if (!array_key_exists($tagElement->value, $callbacks)) {
177+
// We don't have a callback for this tag.
178+
return new Parser\Type\ElementCollection([$tagElement]);
179+
}
180+
181+
$result = ($callbacks[$tagElement->value])(self::CALLBACK_REPLACEMENT);
182+
if (preg_match(self::CALLBACK_RESULT_PATTERN, $result, $matches)) {
183+
$start = new Parser\Type\LiteralElement($matches[1], $tagElement->location);
184+
$middle = $this->processAstWithCallbacks($tagElement->children, $callbacks);
185+
$end = new Parser\Type\LiteralElement($matches[2], $tagElement->location);
186+
187+
return new Parser\Type\ElementCollection([$start, ...array_values($middle->toArray()), $end]);
188+
}
189+
190+
return new Parser\Type\ElementCollection([new Parser\Type\LiteralElement($result, $tagElement->location)]);
191+
}
192+
193+
/**
194+
* @param array<array-key, callable(string):string> $callbacks
195+
*
196+
* @throws CollectionMismatchException
197+
* @throws UnableToFormatMessageException
198+
*/
199+
private function processLiteralElement(
200+
Parser\Type\LiteralElement $literalElement,
201+
array $callbacks
202+
): Parser\Type\LiteralElement {
203+
if (!preg_match(self::LITERAL_TAG_PATTERN, $literalElement->value, $matches)) {
204+
// This isn't a literal tag, so there's nothing to process.
205+
return $literalElement;
206+
}
207+
208+
if (!array_key_exists($matches[1], $callbacks)) {
209+
// We don't have a callback for this tag.
210+
return $literalElement;
211+
}
212+
213+
$result = ($callbacks[$matches[1]])('');
214+
$literalElement->value = $result;
215+
216+
return $literalElement;
217+
}
70218
}

src/Intl/MessageFormatInterface.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,35 @@ interface MessageFormatInterface
4242
* message format instance and replacing any placeholders with the provided
4343
* values
4444
*
45-
* @param array<array-key, float | int | string> $values
45+
* In addition to string and number values, the `$values` parameter may have
46+
* a callable that accepts a string and returns a string. For any callable,
47+
* the array key should match a "tag" embedded in the message.
48+
*
49+
* For example, if you wish to produce the following HTML:
50+
*
51+
* Hello, <a href="/profile/1234">Ben</a>!
52+
*
53+
* Format the message like this:
54+
*
55+
* Hello, <profileLink>{name}</profileLink>!
56+
*
57+
* Then, pass a callable to `$values` with the key `profileLink`. It will
58+
* look something like this:
59+
*
60+
* ```php
61+
* $formatphp->formatMessage(
62+
* [
63+
* 'id' => 'welcome',
64+
* 'defaultMessage' => 'Hello, <profileLink>{name}</profileLink>!',
65+
* ],
66+
* [
67+
* 'name' => 'Ben',
68+
* 'profileLink' => fn (string $text): string => '<a href="/profile/1234">' . $text . '</a>',
69+
* ],
70+
* );
71+
* ```
72+
*
73+
* @param array<array-key, float | int | string | callable(string):string> $values
4674
*
4775
* @throws UnableToFormatMessageException
4876
*/

0 commit comments

Comments
 (0)