Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,24 @@ class Config implements ConfigInterface
private ?LocaleInterface $defaultLocale;
private string $idInterpolatorPattern;

/**
* @var array<string, callable(string):string>
*/
private array $defaultRichTextElements;

/**
* @param array<string, callable(string):string> $defaultRichTextElements
*/
public function __construct(
LocaleInterface $locale,
?LocaleInterface $defaultLocale = null,
array $defaultRichTextElements = [],
string $idInterpolatorPattern = IdInterpolator::DEFAULT_ID_INTERPOLATION_PATTERN
) {
$this->locale = $locale;
$this->defaultLocale = $defaultLocale;
$this->idInterpolatorPattern = $idInterpolatorPattern;
$this->defaultRichTextElements = $defaultRichTextElements;
}

public function getDefaultLocale(): ?LocaleInterface
Expand All @@ -58,4 +68,12 @@ public function getLocale(): LocaleInterface
{
return $this->locale;
}

/**
* @return array<string, callable(string):string>
*/
public function getDefaultRichTextElements(): array
{
return $this->defaultRichTextElements;
}
}
21 changes: 21 additions & 0 deletions src/ConfigInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ interface ConfigInterface
*/
public function getDefaultLocale(): ?LocaleInterface;

/**
* Returns a map of tag names to rich text formatting functions
*
* This is meant to provide a centralized way to format common tags such as
* `<b>`, `<p>`, or enforcing a certain design system in the codebase
* (e.g., standardized `<a>`, `<button>`, etc.).
*
* The functions should be a callable that accepts a single string parameter
* and returns a string. For example:
*
* ```php
* [
* 'em' => fn (string $text): string => '<em class="bar">' . $text . '</em>',
* 'strong' => fn (string $text): string => '<strong class="foo">' . $text . '</strong>',
* ]
* ```
*
* @return array<string, callable(string):string>
*/
public function getDefaultRichTextElements(): array;

/**
* Returns a pattern that defines how to generate missing message IDs
*
Expand Down
5 changes: 5 additions & 0 deletions src/FormatPHP.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use FormatPHP\Util\MessageCleaner;
use FormatPHP\Util\MessageRetriever;

use function array_merge;
use function is_int;

/**
Expand Down Expand Up @@ -62,6 +63,10 @@ public function __construct(
*/
public function formatMessage(array $descriptor, array $values = []): string
{
// Combine the global default rich text element callbacks with the values,
// giving preference to values provided with the same keys.
$values = array_merge($this->config->getDefaultRichTextElements(), $values);

try {
$messagePattern = $this->getMessageForDescriptor(
$this->messages,
Expand Down
7 changes: 5 additions & 2 deletions src/Icu/MessageFormat/Parser/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@

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

/**
* @psalm-type ErrorKind = Error::*
*/
class Error
{
/**
Expand Down Expand Up @@ -166,15 +169,15 @@ class Error
public const UNCLOSED_TAG = 27;

/**
* @psalm-var Error::*
* @var ErrorKind
*/
public int $kind;

public string $message;
public Location $location;

/**
* @psalm-param Error::* $kind
* @param ErrorKind $kind
*/
public function __construct(int $kind, string $message, Location $location)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

/**
* This file is part of skillshare/formatphp
*
* skillshare/formatphp is open source software: you can distribute
* it and/or modify it under the terms of the MIT License
* (the "License"). You may not use this file except in
* compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*
* @copyright Copyright (c) Skillshare, Inc. <https://www.skillshare.com>
* @license https://opensource.org/licenses/MIT MIT License
*/

declare(strict_types=1);

namespace FormatPHP\Icu\MessageFormat\Parser\Exception;

use FormatPHP\Icu\MessageFormat\Parser\Error;
use ReflectionObject;
use RuntimeException as PhpRuntimeException;
use Throwable;

use function array_flip;
use function sprintf;

/**
* Thrown with a message format parser Error to indicate a syntax error
* encountered while parsing a message
*/
class UnableToParseMessageException extends PhpRuntimeException implements ParserExceptionInterface
{
public function __construct(Error $error, ?Throwable $previous = null)
{
parent::__construct($this->createMessageForError($error), 0, $previous);
}

private function createMessageForError(Error $error): string
{
return sprintf(
'Syntax error %s found while parsing message "%s"',
$this->getErrorTypeName($error),
$error->message,
);
}

private function getErrorTypeName(Error $error): string
{
$reflection = new ReflectionObject($error);

// @phpstan-ignore-next-line
$constants = array_flip($reflection->getConstants());

return $constants[$error->kind] ?? '';
}
}
152 changes: 150 additions & 2 deletions src/Intl/MessageFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,34 @@

use FormatPHP\Exception\InvalidArgumentException;
use FormatPHP\Exception\UnableToFormatMessageException;
use IntlException as PhpIntlException;
use FormatPHP\Icu\MessageFormat\Parser;
use FormatPHP\Icu\MessageFormat\Parser\Type\PluralElement;
use FormatPHP\Icu\MessageFormat\Parser\Type\SelectElement;
use FormatPHP\Icu\MessageFormat\Printer;
use Locale as PhpLocale;
use MessageFormatter as PhpMessageFormatter;
use Ramsey\Collection\Exception\CollectionMismatchException;
use Throwable;

use function array_filter;
use function array_key_exists;
use function array_keys;
use function array_values;
use function assert;
use function is_callable;
use function is_int;
use function preg_match;
use function sprintf;

/**
* Formats an ICU message format pattern
*/
class MessageFormat implements MessageFormatInterface
{
private const CALLBACK_REPLACEMENT = '__FORMATPHP_CALLBACK_REPLACEMENT__';
private const CALLBACK_RESULT_PATTERN = '/(.*)' . self::CALLBACK_REPLACEMENT . '(.*)/su';
private const LITERAL_TAG_PATTERN = '/^<(.*)\/>$/su';

private LocaleInterface $locale;

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

return (string) $formatter->format($values);
} catch (PhpIntlException $exception) {
} catch (Throwable $exception) {
throw new UnableToFormatMessageException(
sprintf(
'Unable to format message with pattern "%s" for locale "%s"',
Expand All @@ -67,4 +84,135 @@ public function format(string $pattern, array $values = []): string
);
}
}

/**
* @param array<array-key, float | int | string | callable(string):string> $values
*
* @throws Parser\Exception\IllegalParserUsageException
* @throws Parser\Exception\InvalidArgumentException
* @throws Parser\Exception\InvalidOffsetException
* @throws Parser\Exception\InvalidSkeletonOption
* @throws Parser\Exception\InvalidUtf8CodeBoundaryException
* @throws Parser\Exception\InvalidUtf8CodePointException
* @throws Parser\Exception\UnableToParseMessageException
* @throws UnableToFormatMessageException
* @throws CollectionMismatchException
*/
private function applyCallbacks(string $pattern, array &$values = []): string
{
$callbacks = array_filter($values, fn ($value): bool => is_callable($value));

// If $values doesn't contain any callables, go ahead and return.
if (!$callbacks) {
return $pattern;
}

// Remove the callbacks from the values, since we will use them below.
foreach (array_keys($callbacks) as $key) {
unset($values[$key]);
}

$parser = new Parser($pattern);
$parsed = $parser->parse();

if ($parsed->err !== null) {
throw new Parser\Exception\UnableToParseMessageException($parsed->err);
}

assert($parsed->val instanceof Parser\Type\ElementCollection);

return (new Printer())->printAst($this->processAstWithCallbacks($parsed->val, $callbacks));
}

/**
* @param array<array-key, callable(string):string> $callbacks
*
* @throws CollectionMismatchException
* @throws UnableToFormatMessageException
*/
private function processAstWithCallbacks(
Comment thread
ramsey marked this conversation as resolved.
Parser\Type\ElementCollection $ast,
array $callbacks
): Parser\Type\ElementCollection {
$processedAst = new Parser\Type\ElementCollection();

for ($i = 0; $i < $ast->count(); $i++) {
$element = $ast[$i];
assert($element instanceof Parser\Type\ElementInterface);
$clone = clone $element;

if ($clone instanceof PluralElement || $clone instanceof SelectElement) {
foreach ($clone->options as $option) {
$option->value = $this->processAstWithCallbacks($option->value, $callbacks);
}
}

if ($clone instanceof Parser\Type\TagElement) {
$processedAst = $processedAst->merge($this->processTagElement($clone, $callbacks));

continue;
}

if ($clone instanceof Parser\Type\LiteralElement) {
$clone = $this->processLiteralElement($clone, $callbacks);
}

$processedAst[] = $clone;
}

return $processedAst;
}

/**
* @param array<array-key, callable(string):string> $callbacks
*
* @throws CollectionMismatchException
* @throws UnableToFormatMessageException
*/
private function processTagElement(
Parser\Type\TagElement $tagElement,
array $callbacks
): Parser\Type\ElementCollection {
if (!array_key_exists($tagElement->value, $callbacks)) {
// We don't have a callback for this tag.
return new Parser\Type\ElementCollection([$tagElement]);
}

$result = ($callbacks[$tagElement->value])(self::CALLBACK_REPLACEMENT);
if (preg_match(self::CALLBACK_RESULT_PATTERN, $result, $matches)) {
$start = new Parser\Type\LiteralElement($matches[1], $tagElement->location);
$middle = $this->processAstWithCallbacks($tagElement->children, $callbacks);
$end = new Parser\Type\LiteralElement($matches[2], $tagElement->location);

return new Parser\Type\ElementCollection([$start, ...array_values($middle->toArray()), $end]);
}

return new Parser\Type\ElementCollection([new Parser\Type\LiteralElement($result, $tagElement->location)]);
}

/**
* @param array<array-key, callable(string):string> $callbacks
*
* @throws CollectionMismatchException
* @throws UnableToFormatMessageException
*/
private function processLiteralElement(
Parser\Type\LiteralElement $literalElement,
array $callbacks
): Parser\Type\LiteralElement {
if (!preg_match(self::LITERAL_TAG_PATTERN, $literalElement->value, $matches)) {
// This isn't a literal tag, so there's nothing to process.
return $literalElement;
}

if (!array_key_exists($matches[1], $callbacks)) {
// We don't have a callback for this tag.
return $literalElement;
}

$result = ($callbacks[$matches[1]])('');
$literalElement->value = $result;

return $literalElement;
}
}
30 changes: 29 additions & 1 deletion src/Intl/MessageFormatInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,35 @@ interface MessageFormatInterface
* message format instance and replacing any placeholders with the provided
* values
*
* @param array<array-key, float | int | string> $values
* In addition to string and number values, the `$values` parameter may have
* a callable that accepts a string and returns a string. For any callable,
* the array key should match a "tag" embedded in the message.
*
* For example, if you wish to produce the following HTML:
*
* Hello, <a href="/profile/1234">Ben</a>!
*
* Format the message like this:
*
* Hello, <profileLink>{name}</profileLink>!
*
* Then, pass a callable to `$values` with the key `profileLink`. It will
* look something like this:
*
* ```php
* $formatphp->formatMessage(
* [
* 'id' => 'welcome',
* 'defaultMessage' => 'Hello, <profileLink>{name}</profileLink>!',
* ],
* [
* 'name' => 'Ben',
* 'profileLink' => fn (string $text): string => '<a href="/profile/1234">' . $text . '</a>',
* ],
* );
* ```
*
* @param array<array-key, float | int | string | callable(string):string> $values
*
* @throws UnableToFormatMessageException
*/
Expand Down
Loading