diff --git a/package.json b/package.json index c1519c2..87dce5d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@types/luxon": "^3.3.2", "@types/negotiator": "^0.6.1", "@types/node": "^20.5.9", + "@vinejs/vine": "^1.6.0", "c8": "^8.0.1", "copyfiles": "^2.4.1", "del-cli": "^5.1.0", diff --git a/src/i18n.ts b/src/i18n.ts index c7dc34c..05a7781 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -12,6 +12,7 @@ import type { Emitter } from '@adonisjs/core/events' import type { I18nManager } from './i18n_manager.js' import { Formatter } from './formatters/values_formatter.js' import type { MissingTranslationEventPayload } from './types/main.js' +import { I18nMessagesProvider } from './i18n_messages_provider.js' /** * I18n exposes the APIs to format values and translate messages @@ -41,6 +42,13 @@ export class I18n extends Formatter { return this.#i18nManager.getFallbackLocaleFor(this.locale) } + /** + * Creates a messages provider for VineJS + */ + createMessagesProvider(prefix: string = 'validator.shared') { + return new I18nMessagesProvider(prefix, this) + } + constructor( locale: string, emitter: Emitter<{ 'i18n:missing:translation': MissingTranslationEventPayload } & any>, @@ -67,7 +75,7 @@ export class I18n extends Formatter { /** * Returns the message for a given identifier */ - #getMessage(identifier: string): { message: string; isFallback: boolean } | null { + resolveIdentifier(identifier: string): { message: string; isFallback: boolean } | null { let message = this.localeTranslations[identifier] /** @@ -117,7 +125,7 @@ export class I18n extends Formatter { * Formats a message using the messages formatter */ formatMessage(identifier: string, data?: Record, fallbackMessage?: string): string { - const message = this.#getMessage(identifier) + const message = this.resolveIdentifier(identifier) if (!message) { this.#notifyForMissingTranslation(identifier, false) diff --git a/src/i18n_messages_provider.ts b/src/i18n_messages_provider.ts new file mode 100644 index 0000000..cfb0b4c --- /dev/null +++ b/src/i18n_messages_provider.ts @@ -0,0 +1,87 @@ +/* + * @adonisjs/i18n + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import string from '@poppinss/utils/string' +import type { FieldContext, MessagesProviderContact } from '@vinejs/vine/types' +import type { I18n } from './i18n.js' + +/** + * VineJS messages provider to read validation messages + * from translations + */ +export class I18nMessagesProvider implements MessagesProviderContact { + /** + * The validation messages prefix to use when reading translations. + */ + #messagesPrefix: string + + /** + * The validation fields prefix to use when reading translations. + */ + #fieldsPrefix: string + + /** + * Reference to i18n for formatting messages + */ + #i18n: I18n + + constructor(prefix: string, i18n: I18n) { + this.#fieldsPrefix = `${prefix}.fields` + this.#messagesPrefix = `${prefix}.messages` + this.#i18n = i18n + } + + getMessage( + defaultMessage: string, + rule: string, + field: FieldContext, + meta?: Record + ) { + /** + * Translating field name + */ + let fieldName = field.name + const translatedFieldName = this.#i18n.resolveIdentifier(`${this.#fieldsPrefix}.${field.name}`) + if (translatedFieldName) { + fieldName = this.#i18n.formatRawMessage(translatedFieldName.message) + } + + /** + * 1st priority is given to the field messages + */ + const fieldMessage = this.#i18n.resolveIdentifier( + `${this.#messagesPrefix}.${field.wildCardPath}.${rule}` + ) + if (fieldMessage) { + return this.#i18n.formatRawMessage(fieldMessage.message, { + field: fieldName, + ...meta, + }) + } + + /** + * 2nd priority is for rule messages + */ + const ruleMessage = this.#i18n.resolveIdentifier(`${this.#messagesPrefix}.${rule}`) + if (ruleMessage) { + return this.#i18n.formatRawMessage(ruleMessage.message, { + field: fieldName, + ...meta, + }) + } + + /** + * Fallback to default message + */ + return string.interpolate(defaultMessage, { + field: fieldName, + ...meta, + }) + } +} diff --git a/tests/i18n.spec.ts b/tests/i18n.spec.ts index 7c7f1ce..802a8cf 100644 --- a/tests/i18n.spec.ts +++ b/tests/i18n.spec.ts @@ -16,6 +16,7 @@ import { I18n } from '../src/i18n.js' import { I18nManager } from '../src/i18n_manager.js' import { defineConfig } from '../src/define_config.js' import type { MissingTranslationEventPayload } from '../src/types/main.js' +import vine from '@vinejs/vine' const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) const emitter = new Emitter<{ 'i18n:missing:translation': MissingTranslationEventPayload }>(app) @@ -177,3 +178,121 @@ test.group('I18n', () => { assert.isTrue(i18n.hasFallbackMessage('messages.greeting')) }) }) + +test.group('I18n | validator messages provider', () => { + test('provide validation message', async ({ fs, assert }) => { + assert.plan(1) + + await fs.createJson('resources/lang/en/validator.json', { + shared: { + messages: { + 'title.required': 'Post title is required', + 'required': 'The {field} is needed', + }, + }, + }) + + const i18nManager = new I18nManager( + emitter, + defineConfig({ + loaders: { + fs: { + enabled: true, + location: join(fs.basePath, 'resources/lang'), + }, + }, + }) + ) + + await i18nManager.loadTranslations() + const i18n = new I18n('en', emitter, i18nManager) + + const schema = vine.object({ + title: vine.string(), + description: vine.string(), + tags: vine.enum(['programming']), + }) + + try { + await vine.validate({ + schema, + data: { tags: '' }, + messagesProvider: i18n.createMessagesProvider(), + }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + field: 'title', + message: 'Post title is required', + rule: 'required', + }, + { + field: 'description', + message: 'The description is needed', + rule: 'required', + }, + { + field: 'tags', + message: 'The selected tags is invalid', + rule: 'enum', + meta: { + choices: ['programming'], + }, + }, + ]) + } + }) + + test('provide field translations', async ({ fs, assert }) => { + assert.plan(1) + + await fs.createJson('resources/lang/en/validator.json', { + shared: { + fields: { + title: 'Post title', + description: 'Post description', + }, + messages: { + required: 'The {field} is needed', + }, + }, + }) + + const i18nManager = new I18nManager( + emitter, + defineConfig({ + loaders: { + fs: { + enabled: true, + location: join(fs.basePath, 'resources/lang'), + }, + }, + }) + ) + + await i18nManager.loadTranslations() + const i18n = new I18n('en', emitter, i18nManager) + + const schema = vine.object({ + title: vine.string(), + description: vine.string(), + }) + + try { + await vine.validate({ schema, data: {}, messagesProvider: i18n.createMessagesProvider() }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + field: 'title', + message: 'The Post title is needed', + rule: 'required', + }, + { + field: 'description', + message: 'The Post description is needed', + rule: 'required', + }, + ]) + } + }) +})