diff --git a/src/Exception/InvalidMessageShapeException.php b/src/Exception/InvalidMessageShapeException.php new file mode 100644 index 0000000..430ede4 --- /dev/null +++ b/src/Exception/InvalidMessageShapeException.php @@ -0,0 +1,35 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Exception; + +use FormatPHP\Reader\FormatInterface; +use RuntimeException as PhpRuntimeException; + +/** + * Thrown when reading a message that doesn't conform to the expected shape + * + * @see FormatInterface + */ +class InvalidMessageShapeException extends PhpRuntimeException implements FormatPHPExceptionInterface +{ +} diff --git a/src/Exception/LocaleNotFoundException.php b/src/Exception/LocaleNotFoundException.php new file mode 100644 index 0000000..d2604ac --- /dev/null +++ b/src/Exception/LocaleNotFoundException.php @@ -0,0 +1,32 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Exception; + +use RuntimeException as PhpRuntimeException; + +/** + * Thrown when unable to find a given locale messages file + */ +class LocaleNotFoundException extends PhpRuntimeException implements FormatPHPExceptionInterface +{ +} diff --git a/src/MessageLoader.php b/src/MessageLoader.php new file mode 100644 index 0000000..bc6231b --- /dev/null +++ b/src/MessageLoader.php @@ -0,0 +1,141 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP; + +use FormatPHP\Exception\InvalidArgumentException; +use FormatPHP\Exception\InvalidMessageShapeException; +use FormatPHP\Exception\LocaleNotFoundException; +use FormatPHP\Exception\UnableToProcessFileException; +use FormatPHP\Intl\Locale; +use FormatPHP\Intl\LocaleInterface; +use FormatPHP\Reader\FormatInterface; +use FormatPHP\Util\FileSystemHelper; + +use function array_filter; +use function array_unique; +use function array_values; +use function implode; +use function sprintf; + +use const DIRECTORY_SEPARATOR; + +/** + * Loads messages for a given locale from the file system or cache + */ +final class MessageLoader +{ + private Config $config; + private FileSystemHelper $fileSystemHelper; + private FormatInterface $formatReader; + private string $messagesDirectory; + + /** + * @throws InvalidArgumentException + */ + public function __construct( + string $messagesDirectory, + Config $config, + FormatInterface $formatReader, + ?FileSystemHelper $fileSystemHelper = null + ) { + $this->config = $config; + $this->formatReader = $formatReader; + $this->fileSystemHelper = $fileSystemHelper ?? new FileSystemHelper(); + $this->messagesDirectory = $this->fileSystemHelper->getRealPath($messagesDirectory); + + if (!$this->fileSystemHelper->isDirectory($this->messagesDirectory)) { + throw new InvalidArgumentException(sprintf( + 'Messages directory "%s" is not a valid directory', + $messagesDirectory, + )); + } + } + + /** + * Returns a MessageCollection according to the configuration provided to + * this MessageLoader + * + * @throws InvalidArgumentException + * @throws InvalidMessageShapeException + * @throws LocaleNotFoundException + */ + public function loadMessages(): MessageCollection + { + [$messagesData, $resolvedLocale] = $this->getLocaleMessages(); + + return ($this->formatReader)($this->config, $messagesData, $resolvedLocale); + } + + /** + * @return array{0: array, 1: LocaleInterface} + * + * @throws InvalidArgumentException + * @throws LocaleNotFoundException + */ + private function getLocaleMessages(): array + { + $messagesContents = false; + $localeResolved = null; + + foreach ($this->getFallbackLocales() as $locale) { + try { + $messagesFile = $this->messagesDirectory . DIRECTORY_SEPARATOR . $locale . '.json'; + $messagesContents = $this->fileSystemHelper->getJsonContents($messagesFile); + $localeResolved = new Locale($locale); + + break; + } catch (UnableToProcessFileException $exception) { + continue; + } + } + + if ($messagesContents === false || $localeResolved === null) { + throw new LocaleNotFoundException(sprintf( + 'Unable to find a suitable locale for "%s"; please set a default locale', + $this->config->getLocale()->toString(), + )); + } + + return [$messagesContents, $localeResolved]; + } + + /** + * @return string[] + */ + private function getFallbackLocales(): array + { + $locale = $this->config->getLocale(); + $defaultLocale = $this->config->getDefaultLocale(); + + $fallbacks = [ + $locale->toString(), + $locale->baseName(), + implode('-', array_filter([$locale->language(), $locale->region()])), + $locale->language(), + $defaultLocale ? $defaultLocale->toString() : null, + ]; + + /** @var string[] */ + return array_values(array_unique(array_filter($fallbacks))); + } +} diff --git a/src/Reader/Format/FormatPHP.php b/src/Reader/Format/FormatPHP.php new file mode 100644 index 0000000..8b50e32 --- /dev/null +++ b/src/Reader/Format/FormatPHP.php @@ -0,0 +1,90 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Reader\Format; + +use FormatPHP\Config; +use FormatPHP\Exception\InvalidMessageShapeException; +use FormatPHP\Intl\LocaleInterface; +use FormatPHP\Message; +use FormatPHP\MessageCollection; +use FormatPHP\Reader\FormatInterface; + +use function assert; +use function gettype; +use function is_array; +use function is_string; +use function sprintf; + +/** + * Returns a MessageCollection parsed from JSON-decoded data that was written + * using Writer\Format\FormatPHP + * + * @see \FormatPHP\Writer\Format\FormatPHP + */ +class FormatPHP implements FormatInterface +{ + /** + * @inheritdoc + */ + public function __invoke(Config $config, array $data, LocaleInterface $localeResolved): MessageCollection + { + $messages = new MessageCollection($config); + + foreach ($data as $messageId => $message) { + $this->validateShape($messageId, $message); + assert(is_string($messageId)); + assert(isset($message['defaultMessage'])); + assert(is_string($message['defaultMessage'])); + + $messages[] = new Message($localeResolved, $messageId, $message['defaultMessage']); + } + + return $messages; + } + + /** + * @param array-key $messageId + * @param mixed $message + * + * @throws InvalidMessageShapeException + * + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + private function validateShape($messageId, $message): void + { + if (!is_string($messageId)) { + throw new InvalidMessageShapeException(sprintf( + '%s expects a string message ID; received %s', + self::class, + gettype($messageId), + )); + } + + if (!is_array($message) || !is_string($message['defaultMessage'] ?? null)) { + throw new InvalidMessageShapeException(sprintf( + '%s expects a string defaultMessage property; defaultMessage does not exist or is not a string', + self::class, + )); + } + } +} diff --git a/src/Reader/Format/Simple.php b/src/Reader/Format/Simple.php new file mode 100644 index 0000000..03536f6 --- /dev/null +++ b/src/Reader/Format/Simple.php @@ -0,0 +1,89 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Reader\Format; + +use FormatPHP\Config; +use FormatPHP\Exception\InvalidMessageShapeException; +use FormatPHP\Intl\LocaleInterface; +use FormatPHP\Message; +use FormatPHP\MessageCollection; +use FormatPHP\Reader\FormatInterface; + +use function assert; +use function gettype; +use function is_string; +use function sprintf; + +/** + * Returns a MessageCollection parsed from JSON-decoded data that was written + * using Writer\Format\Simple + * + * @see \FormatPHP\Writer\Format\Simple + */ +class Simple implements FormatInterface +{ + /** + * @inheritdoc + */ + public function __invoke(Config $config, array $data, LocaleInterface $localeResolved): MessageCollection + { + $messages = new MessageCollection($config); + + foreach ($data as $messageId => $message) { + $this->validateShape($messageId, $message); + assert(is_string($messageId)); + assert(is_string($message)); + + $messages[$messageId] = new Message($localeResolved, $messageId, $message); + } + + return $messages; + } + + /** + * @param array-key $messageId + * @param mixed $message + * + * @throws InvalidMessageShapeException + * + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + private function validateShape($messageId, $message): void + { + if (!is_string($messageId)) { + throw new InvalidMessageShapeException(sprintf( + '%s expects a string message ID; received %s', + self::class, + gettype($messageId), + )); + } + + if (!is_string($message)) { + throw new InvalidMessageShapeException(sprintf( + '%s expects a string message; received %s', + self::class, + gettype($message), + )); + } + } +} diff --git a/src/Reader/Format/Smartling.php b/src/Reader/Format/Smartling.php new file mode 100644 index 0000000..aad282a --- /dev/null +++ b/src/Reader/Format/Smartling.php @@ -0,0 +1,92 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Reader\Format; + +use FormatPHP\Config; +use FormatPHP\Exception\InvalidMessageShapeException; +use FormatPHP\Intl\LocaleInterface; +use FormatPHP\Message; +use FormatPHP\MessageCollection; +use FormatPHP\Reader\FormatInterface; + +use function assert; +use function gettype; +use function is_array; +use function is_string; +use function sprintf; + +/** + * Returns a MessageCollection parsed from JSON-decoded data that was written + * using Writer\Format\Smartling + * + * @see \FormatPHP\Writer\Format\Smartling + */ +class Smartling implements FormatInterface +{ + /** + * @inheritdoc + */ + public function __invoke(Config $config, array $data, LocaleInterface $localeResolved): MessageCollection + { + $messages = new MessageCollection($config); + + unset($data['smartling']); + + foreach ($data as $messageId => $message) { + $this->validateShape($messageId, $message); + assert(is_string($messageId)); + assert(isset($message['message'])); + assert(is_string($message['message'])); + + $messages[] = new Message($localeResolved, $messageId, $message['message']); + } + + return $messages; + } + + /** + * @param array-key $messageId + * @param mixed $message + * + * @throws InvalidMessageShapeException + * + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + private function validateShape($messageId, $message): void + { + if (!is_string($messageId)) { + throw new InvalidMessageShapeException(sprintf( + '%s expects a string message ID; received %s', + self::class, + gettype($messageId), + )); + } + + if (!is_array($message) || !is_string($message['message'] ?? null)) { + throw new InvalidMessageShapeException(sprintf( + '%s expects a string message property; message does not exist or is not a string', + self::class, + )); + } + } +} diff --git a/src/Reader/FormatInterface.php b/src/Reader/FormatInterface.php new file mode 100644 index 0000000..97ec2dc --- /dev/null +++ b/src/Reader/FormatInterface.php @@ -0,0 +1,47 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Reader; + +use FormatPHP\Config; +use FormatPHP\Exception\InvalidMessageShapeException; +use FormatPHP\Intl\LocaleInterface; +use FormatPHP\MessageCollection; + +/** + * Returns a collection of messages parsed from JSON-decoded message data + */ +interface FormatInterface +{ + /** + * @param array $data An arbitrary array of JSON-decoded + * data, loaded from a message file. + * @param LocaleInterface $localeResolved We utilize a "fallback" algorithm + * to look up a suitable replacement locale (i.e., if we receive "en-US" + * and have only a locale for "en," we will use "en" instead). This + * parameter is the actual locale we used, which may be different from + * the one provided on Config. + * + * @throws InvalidMessageShapeException + */ + public function __invoke(Config $config, array $data, LocaleInterface $localeResolved): MessageCollection; +} diff --git a/src/Util/FileSystemHelper.php b/src/Util/FileSystemHelper.php index aa51cc8..2b2aa65 100644 --- a/src/Util/FileSystemHelper.php +++ b/src/Util/FileSystemHelper.php @@ -27,6 +27,7 @@ use FormatPHP\Exception\InvalidArgumentException; use FormatPHP\Exception\UnableToProcessFileException; use FormatPHP\Exception\UnableToWriteFileException; +use JsonException; use function file_get_contents; use function file_put_contents; @@ -39,9 +40,14 @@ use function is_readable; use function is_resource; use function is_string; +use function json_decode; +use function realpath; use function sprintf; use function strlen; +use const JSON_BIGINT_AS_STRING; +use const JSON_INVALID_UTF8_SUBSTITUTE; +use const JSON_THROW_ON_ERROR; use const PHP_SAPI; /** @@ -49,6 +55,8 @@ */ class FileSystemHelper { + private const JSON_DECODE_FLAGS = JSON_BIGINT_AS_STRING | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR; + private static ?string $currentWorkingDir = null; /** @@ -78,6 +86,27 @@ public function getContents(string $filePath): string return $contents; } + /** + * @return array + * + * @throws UnableToProcessFileException + */ + public function getJsonContents(string $filePath): array + { + $contents = $this->getContents($filePath); + + try { + /** @var array */ + return @json_decode($contents, true, 512, self::JSON_DECODE_FLAGS); + } catch (JsonException $exception) { + throw new UnableToProcessFileException( + sprintf('Unable to decode the JSON in the file "%s"', $filePath), + (int) $exception->getCode(), + $exception, + ); + } + } + /** * @param string | resource | mixed $file * @@ -110,6 +139,23 @@ public function writeContents($file, string $contents): void } } + /** + * @throws InvalidArgumentException + */ + public function getRealPath(string $path): string + { + $realpath = @realpath($path); + + if ($realpath === false) { + throw new InvalidArgumentException(sprintf( + 'Unable to find or access the path at "%s"', + $path, + )); + } + + return $realpath; + } + /** * Returns `true` if the path is a directory */ diff --git a/tests/MessageLoaderTest.php b/tests/MessageLoaderTest.php new file mode 100644 index 0000000..f54ef6f --- /dev/null +++ b/tests/MessageLoaderTest.php @@ -0,0 +1,96 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Messages directory "%s" is not a valid directory', + __FILE__, + )); + + new MessageLoader( + __FILE__, + new Config(new Locale('en')), + $this->mockery(FormatInterface::class), + ); + } + + public function testExceptionWhenUnableToFindSuitableLocale(): void + { + // Esperanto, Latin script, US region. + $locale = new Locale('eo-Latn-US'); + + $loader = new MessageLoader( + __DIR__ . '/fixtures/locales', + new Config($locale), + $this->mockery(FormatInterface::class), + ); + + $this->expectException(LocaleNotFoundException::class); + $this->expectExceptionMessage('Unable to find a suitable locale for "eo-Latn-US"; please set a default locale'); + + $loader->loadMessages(); + } + + public function testLoadMessagesFallsBackToDefaultLocale(): void + { + // Esperanto, Latin script, US region. + $locale = new Locale('eo-Latn-US'); + + $defaultLocale = new Locale('en'); + + $loader = new MessageLoader( + __DIR__ . '/fixtures/locales', + new Config($locale, $defaultLocale), + new FormatPHP(), + ); + + $collection = $loader->loadMessages(); + + $this->assertCount(1, $collection); + $this->assertNotNull($collection['about.inspire']); + $this->assertInstanceOf(MessageInterface::class, $collection['about.inspire']); + $this->assertSame('en', $collection['about.inspire']->getLocale()->toString()); + } + + public function testLoadMessagesWithFallback(): void + { + $locale = new Locale('ar-Arab-AE-VARIANT1'); + + $defaultLocale = new Locale('en'); + + $loader = new MessageLoader( + __DIR__ . '/fixtures/locales', + new Config($locale, $defaultLocale), + new FormatPHP(), + ); + + $collection = $loader->loadMessages(); + + $this->assertCount(1, $collection); + $this->assertNotNull($collection['about.inspire']); + $this->assertInstanceOf(MessageInterface::class, $collection['about.inspire']); + $this->assertSame('ar', $collection['about.inspire']->getLocale()->toString()); + $this->assertSame( + 'في Skillshare ، نقوم بتمكين الأعضاء للحصول على الإلهام.', + $collection['about.inspire']->getMessage(), + ); + } +} diff --git a/tests/Reader/Format/FormatPHPTest.php b/tests/Reader/Format/FormatPHPTest.php new file mode 100644 index 0000000..e017a53 --- /dev/null +++ b/tests/Reader/Format/FormatPHPTest.php @@ -0,0 +1,70 @@ +expectException(InvalidMessageShapeException::class); + $this->expectExceptionMessage(sprintf( + '%s expects a string message ID; received integer', + FormatPHP::class, + )); + + $formatReader($config, $data, $locale); + } + + public function testThrowsExceptionWhenMessageIsNotAString(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $formatReader = new FormatPHP(); + $data = ['foo' => ['bar']]; + + $this->expectException(InvalidMessageShapeException::class); + $this->expectExceptionMessage(sprintf( + '%s expects a string defaultMessage property; defaultMessage does not exist or is not a string', + FormatPHP::class, + )); + + $formatReader($config, $data, $locale); + } + + public function testInvoke(): void + { + $locale = new Locale('en-US'); + $localeResolved = new Locale('en'); + $config = new Config($locale); + $formatReader = new FormatPHP(); + $data = ['foo' => ['defaultMessage' => 'I am foo'], 'bar' => ['defaultMessage' => 'I am bar']]; + + $collection = $formatReader($config, $data, $localeResolved); + + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertCount(2, $collection); + $this->assertInstanceOf(MessageInterface::class, $collection['foo']); + $this->assertSame('I am foo', $collection['foo']->getMessage()); + $this->assertSame($localeResolved, $collection['foo']->getLocale()); + $this->assertInstanceOf(MessageInterface::class, $collection['bar']); + $this->assertSame('I am bar', $collection['bar']->getMessage()); + $this->assertSame($localeResolved, $collection['bar']->getLocale()); + } +} diff --git a/tests/Reader/Format/SimpleTest.php b/tests/Reader/Format/SimpleTest.php new file mode 100644 index 0000000..c802f46 --- /dev/null +++ b/tests/Reader/Format/SimpleTest.php @@ -0,0 +1,70 @@ +expectException(InvalidMessageShapeException::class); + $this->expectExceptionMessage(sprintf( + '%s expects a string message ID; received integer', + Simple::class, + )); + + $formatReader($config, $data, $locale); + } + + public function testThrowsExceptionWhenMessageIsNotAString(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $formatReader = new Simple(); + $data = ['foo' => ['bar']]; + + $this->expectException(InvalidMessageShapeException::class); + $this->expectExceptionMessage(sprintf( + '%s expects a string message; received array', + Simple::class, + )); + + $formatReader($config, $data, $locale); + } + + public function testInvoke(): void + { + $locale = new Locale('en-US'); + $localeResolved = new Locale('en'); + $config = new Config($locale); + $formatReader = new Simple(); + $data = ['foo' => 'I am foo', 'bar' => 'I am bar']; + + $collection = $formatReader($config, $data, $localeResolved); + + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertCount(2, $collection); + $this->assertInstanceOf(MessageInterface::class, $collection['foo']); + $this->assertSame('I am foo', $collection['foo']->getMessage()); + $this->assertSame($localeResolved, $collection['foo']->getLocale()); + $this->assertInstanceOf(MessageInterface::class, $collection['bar']); + $this->assertSame('I am bar', $collection['bar']->getMessage()); + $this->assertSame($localeResolved, $collection['bar']->getLocale()); + } +} diff --git a/tests/Reader/Format/SmartlingTest.php b/tests/Reader/Format/SmartlingTest.php new file mode 100644 index 0000000..ae6091b --- /dev/null +++ b/tests/Reader/Format/SmartlingTest.php @@ -0,0 +1,70 @@ + [], 'foo']; + + $this->expectException(InvalidMessageShapeException::class); + $this->expectExceptionMessage(sprintf( + '%s expects a string message ID; received integer', + Smartling::class, + )); + + $formatReader($config, $data, $locale); + } + + public function testThrowsExceptionWhenMessageIsNotAString(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $formatReader = new Smartling(); + $data = ['smartling' => [], 'foo' => ['bar']]; + + $this->expectException(InvalidMessageShapeException::class); + $this->expectExceptionMessage(sprintf( + '%s expects a string message property; message does not exist or is not a string', + Smartling::class, + )); + + $formatReader($config, $data, $locale); + } + + public function testInvoke(): void + { + $locale = new Locale('en-US'); + $localeResolved = new Locale('en'); + $config = new Config($locale); + $formatReader = new Smartling(); + $data = ['smartling' => [], 'foo' => ['message' => 'I am foo'], 'bar' => ['message' => 'I am bar']]; + + $collection = $formatReader($config, $data, $localeResolved); + + $this->assertInstanceOf(MessageCollection::class, $collection); + $this->assertCount(2, $collection); + $this->assertInstanceOf(MessageInterface::class, $collection['foo']); + $this->assertSame('I am foo', $collection['foo']->getMessage()); + $this->assertSame($localeResolved, $collection['foo']->getLocale()); + $this->assertInstanceOf(MessageInterface::class, $collection['bar']); + $this->assertSame('I am bar', $collection['bar']->getMessage()); + $this->assertSame($localeResolved, $collection['bar']->getLocale()); + } +} diff --git a/tests/Util/FileSystemHelperTest.php b/tests/Util/FileSystemHelperTest.php index 60a2193..3334109 100644 --- a/tests/Util/FileSystemHelperTest.php +++ b/tests/Util/FileSystemHelperTest.php @@ -20,6 +20,7 @@ use function getcwd; use function is_resource; use function is_writable; +use function realpath; use function sprintf; use function sys_get_temp_dir; use function tempnam; @@ -236,4 +237,44 @@ public function testWriteContentsToFile(): void unlink($tmpFile); } + + public function testGetRealPathThrowsExceptionWhenUnableToAccessFile(): void + { + $helper = new FileSystemHelper(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to find or access the path at "foo/bar"'); + + $helper->getRealPath('foo/bar'); + } + + public function testGetRealPath(): void + { + $helper = new FileSystemHelper(); + + $this->assertSame(realpath('.'), $helper->getRealPath('.')); + } + + public function testGetJsonContentsThrowsExceptionWhenUnableToParseJson(): void + { + $helper = new FileSystemHelper(); + + $this->expectException(UnableToProcessFileException::class); + $this->expectExceptionMessage(sprintf( + 'Unable to decode the JSON in the file "%s"', + __DIR__ . '/fixtures/get-json-contents-01.json', + )); + + $helper->getJsonContents(__DIR__ . '/fixtures/get-json-contents-01.json'); + } + + public function testGetJsonContents(): void + { + $helper = new FileSystemHelper(); + + $this->assertSame( + ['foo' => 'bar'], + $helper->getJsonContents(__DIR__ . '/fixtures/get-json-contents-02.json'), + ); + } } diff --git a/tests/Util/fixtures/get-json-contents-01.json b/tests/Util/fixtures/get-json-contents-01.json new file mode 100644 index 0000000..0ed10f5 --- /dev/null +++ b/tests/Util/fixtures/get-json-contents-01.json @@ -0,0 +1 @@ +this is not a json file diff --git a/tests/Util/fixtures/get-json-contents-02.json b/tests/Util/fixtures/get-json-contents-02.json new file mode 100644 index 0000000..e63d37b --- /dev/null +++ b/tests/Util/fixtures/get-json-contents-02.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/tests/fixtures/locales/ar.json b/tests/fixtures/locales/ar.json new file mode 100644 index 0000000..225ccb2 --- /dev/null +++ b/tests/fixtures/locales/ar.json @@ -0,0 +1,6 @@ +{ + "about.inspire": { + "defaultMessage": "في Skillshare ، نقوم بتمكين الأعضاء للحصول على الإلهام.", + "description": "Marketing text found at https://skillshare.com/about" + } +} diff --git a/tests/fixtures/locales/en.json b/tests/fixtures/locales/en.json new file mode 100644 index 0000000..bca9b21 --- /dev/null +++ b/tests/fixtures/locales/en.json @@ -0,0 +1,6 @@ +{ + "about.inspire": { + "defaultMessage": "At Skillshare, we empower members to get inspired.", + "description": "Marketing text found at https://skillshare.com/about" + } +}