Skip to content

Commit

Permalink
add localization support (#16)
Browse files Browse the repository at this point in the history
* add localization support

* add pluralization back in

* add localization tests

* update readme

* update changelog

---------

Co-authored-by: Kyriakos Nicola <knicola@users.noreply.github.com>
  • Loading branch information
knicola and knicola authored Mar 31, 2024
1 parent 86af1d1 commit 0fe88d7
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 36 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [next-version] - next-release-date
### Added
* Localization support.

### Changed
* Switched to Typescript.

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ const schema = yup.string().min(6).minUppercase(3).maxRepeating(2).minWords(2)
await schema.isValid('Now, THIS is some password.') // => true
await schema.isValid('But thiiis is not.') // => false
```
Localize your error messages:
```js
yup.setLocale({
string: {
minLowercase: 'Localized message (path=${path};length=${length})',
minUppercase: 'Localized message (path=${path};length=${length})',
minNumbers: 'Localized message (path=${path};length=${length})',
minSymbols: 'Localized message (path=${path};length=${length})',
maxRepeating: 'Localized message (path=${path};length=${length})',
minWords: 'Localized message (path=${path};length=${length})',
}, // when using typescript, you may want to append `as any` to the end
// of this object to avoid type errors.
})
```

## API

Expand Down
29 changes: 29 additions & 0 deletions examples/localization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* eslint-disable no-console, no-template-curly-in-string */
import * as yup from 'yup'
import YupPassword from '../src'
YupPassword(yup)

// StringLocale declaration merging does not seem to
// work, so we have to declare yup password's locale
// overrides as a separate "Record" object.
const locale: Record<string, any> = {
minSymbols: 'This is now localized. path=${path};length=${length}',
}

yup.setLocale({
string: {
...locale,
// Add other messages here
},
})

const schema = yup.object({
password: yup.string().password(),
})

const input = {
password: 'Password1',
}

schema.validate(input, { abortEarly: false })
.catch(e => console.error(e.errors))
88 changes: 52 additions & 36 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
/* eslint-disable @typescript-eslint/method-signature-style, no-template-curly-in-string */
/* eslint-disable
@typescript-eslint/restrict-template-expressions,
@typescript-eslint/method-signature-style,
no-template-curly-in-string
*/

// NOTES.
// 1. error message pluralization will have to go away to support localization (reason: yup limitation)
// 2. (breaking) in a future release setup will be immediately invoked and the export will be removed

// pluralize
function p (word: string, num: number): string {
return num === 1 ? word : `${word}s`
}

function isNullOrUndefined (value: any): value is null | undefined {
return value === null || value === undefined
}
import type { Message } from 'yup'

declare module 'yup' {
// for declaration merging to work, StringLocale prob needs to be exported from yup
interface StringLocale {
// ..rename params from length to min, max, etc?
minLowercase?: Message<{ length: number }>
minUppercase?: Message<{ length: number }>
minNumbers?: Message<{ length: number }>
minSymbols?: Message<{ length: number }>
maxRepeating?: Message<{ length: number }>
minWords?: Message<{ length: number }>
}
interface StringSchema {
minLowercase (length?: number, message?: string): StringSchema
minUppercase (length?: number, message?: string): StringSchema
Expand All @@ -25,88 +28,101 @@ declare module 'yup' {
}
}

export default function setup (yup: typeof import('yup')): void {
yup.addMethod(yup.string, 'minLowercase', function minLowercase (length: number = 1, message?: string) {
const msg = message || '${path} must contain at least ${length} lowercase ' + p('letter', length)
// pluralize
function p (word: string, num: number): string {
return num === 1 ? word : `${word}s`
}

const messages: Record<string, any> = {
minLowercase: ({ path, length }) => `${path} must contain at least ${length} lowercase ${p('letter', length)}`,
minUppercase: ({ path, length }) => `${path} must contain at least ${length} uppercase ${p('letter', length)}`,
minNumbers: ({ path, length }) => `${path} must contain at least ${length} ${p('number', length)}`,
minSymbols: ({ path, length }) => `${path} must contain at least ${length} ${p('symbol', length)}`,
maxRepeating: ({ path, length }) => `${path} must not contain sequences of more than ${length} repeated characters`,
minWords: ({ path, length }) => `${path} must contain at least ${length} ${p('word', length)}`,
}

function isNullOrUndefined (value: any): value is null | undefined {
return value === null || value === undefined
}

export default function setup ({ setLocale, defaultLocale, addMethod, string }: typeof import('yup')): void {
setLocale({ string: messages })
const locale = defaultLocale.string as typeof messages

addMethod(string, 'minLowercase', function minLowercase (length: number = 1, message: string = locale?.minLowercase) {
return this.test({
message,
name: 'minLowercase',
exclusive: true,
message: msg,
params: { length },
test (value) {
return isNullOrUndefined(value) || (value.match(/[a-z]/g) || []).length >= length
},
})
})

yup.addMethod(yup.string, 'minUppercase', function minUppercase (length: number = 1, message?: string) {
const msg = message || '${path} must contain at least ${length} uppercase ' + p('letter', length)
addMethod(string, 'minUppercase', function minUppercase (length: number = 1, message: string = locale?.minUppercase) {
return this.test({
message,
name: 'minUppercase',
exclusive: true,
message: msg,
params: { length },
test (value) {
return isNullOrUndefined(value) || (value.match(/[A-Z]/g) || []).length >= length
},
})
})

yup.addMethod(yup.string, 'minNumbers', function minNumbers (length: number = 1, message?: string) {
const msg = message || '${path} must contain at least ${length} ' + p('number', length)
addMethod(string, 'minNumbers', function minNumbers (length: number = 1, message: string = locale?.minNumbers) {
return this.test({
message,
name: 'minNumbers',
exclusive: true,
message: msg,
params: { length },
test (value) {
return isNullOrUndefined(value) || (value.match(/[0-9]/g) || []).length >= length
},
})
})

yup.addMethod(yup.string, 'minSymbols', function minSymbols (length: number = 1, message?: string) {
const msg = message || '${path} must contain at least ${length} ' + p('symbol', length)
addMethod(string, 'minSymbols', function minSymbols (length: number = 1, message: string = locale?.minSymbols) {
return this.test({
message,
name: 'minSymbols',
exclusive: true,
message: msg,
params: { length },
test (value) {
return isNullOrUndefined(value) || (value.match(/[^a-zA-Z0-9\s]/g) || []).length >= length
},
})
})

yup.addMethod(yup.string, 'maxRepeating', function maxRepeating (length: number = 2, message?: string) {
const msg = message || '${path} must not contain sequences of more than ${length} repeated ' + p('character', length)
addMethod(string, 'maxRepeating', function maxRepeating (length: number = 2, message: string = locale?.maxRepeating) {
return this.test({
message,
name: 'maxRepeating',
exclusive: true,
message: msg,
params: { length },
test (value) {
return isNullOrUndefined(value) || ! new RegExp(`(.)\\1{${length},}`).test(value)
},
})
})

yup.addMethod(yup.string, 'minWords', function minWords (length: number = 2, message?: string) {
const msg = message || '${path} must contain at least ${length} ' + p('word', length)
// eslint-disable-next-line prefer-regex-literals
const rx = new RegExp('[a-zA-Z0-9]')
addMethod(string, 'minWords', function minWords (length: number = 2, message: string = locale?.minWords) {
return this.test({
message,
name: 'minWords',
exclusive: true,
message: msg,
params: { length },
test (value) {
return isNullOrUndefined(value) || value.split(' ').filter(v => !! v && rx.test(v)).length >= length
return isNullOrUndefined(value) || value.split(' ').filter(v => !! v && /[a-zA-Z0-9]/.test(v)).length >= length
},
})
})

yup.addMethod(yup.string, 'password', function password () {
addMethod(string, 'password', function password () {
return this
.min(8)
.max(250)
Expand Down
118 changes: 118 additions & 0 deletions tests/locale.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/* eslint-disable
no-template-curly-in-string
*/

import * as yup from 'yup'
import setup from '../src'
setup(yup)
const schema = yup.string()

describe('Locale support', () => {
describe('test default message: singular', () => {
test('minLowercase()', async () => {
const errorMessage = await schema.minLowercase(1).validate('A').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 1 lowercase letter')
})

test('minUppercase()', async () => {
const errorMessage = await schema.minUppercase(1).validate('a').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 1 uppercase letter')
})

test('minNumbers()', async () => {
const errorMessage = await schema.minNumbers(1).validate('a').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 1 number')
})

test('minSymbols()', async () => {
const errorMessage = await schema.minSymbols(1).validate('a').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 1 symbol')
})

test('maxRepeating()', async () => {
const errorMessage = await schema.maxRepeating(1).validate('aa').catch(e => e.message)
expect(errorMessage).toBe('this must not contain sequences of more than 1 repeated characters')
})

test('minWords()', async () => {
const errorMessage = await schema.minWords(1).validate('$').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 1 word')
})
}) // group

describe('test default message: plural', () => {
test('minLowercase()', async () => {
const errorMessage = await schema.minLowercase(2).validate('a').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 2 lowercase letters')
})

test('minUppercase()', async () => {
const errorMessage = await schema.minUppercase(2).validate('A').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 2 uppercase letters')
})

test('minNumbers()', async () => {
const errorMessage = await schema.minNumbers(2).validate('1').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 2 numbers')
})

test('minSymbols()', async () => {
const errorMessage = await schema.minSymbols(2).validate('!').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 2 symbols')
})

test('maxRepeating()', async () => {
const errorMessage = await schema.maxRepeating(2).validate('aaa').catch(e => e.message)
expect(errorMessage).toBe('this must not contain sequences of more than 2 repeated characters')
})

test('minWords()', async () => {
const errorMessage = await schema.minWords(2).validate('a').catch(e => e.message)
expect(errorMessage).toBe('this must contain at least 2 words')
})
}) // group

describe('test localized message', () => {
beforeAll(() => {
yup.setLocale({
string: {
minLowercase: 'name=minLowercase;path=${path};length=${length}',
minUppercase: 'name=minUppercase;path=${path};length=${length}',
minNumbers: 'name=minNumbers;path=${path};length=${length}',
minSymbols: 'name=minSymbols;path=${path};length=${length}',
maxRepeating: 'name=maxRepeating;path=${path};length=${length}',
minWords: 'name=minWords;path=${path};length=${length}',
} as any,
})
})
test('minLowercase()', async () => {
const errorMessage = await schema.minLowercase(2).validate('a').catch(e => e.message)
expect(errorMessage).toBe('name=minLowercase;path=this;length=2')
})

test('minUppercase()', async () => {
const errorMessage = await schema.minUppercase(2).validate('A').catch(e => e.message)
expect(errorMessage).toBe('name=minUppercase;path=this;length=2')
})

test('minNumbers()', async () => {
const errorMessage = await schema.minNumbers(2).validate('1').catch(e => e.message)
expect(errorMessage).toBe('name=minNumbers;path=this;length=2')
})

test('minSymbols()', async () => {
const errorMessage = await schema.minSymbols(2).validate('!').catch(e => e.message)
expect(errorMessage).toBe('name=minSymbols;path=this;length=2')
})

test('maxRepeating()', async () => {
const errorMessage = await schema.maxRepeating(2).validate('aaa').catch(e => e.message)
expect(errorMessage).toBe('name=maxRepeating;path=this;length=2')
})

test('minWords()', async () => {
const errorMessage = await schema.minWords(2).validate('a').catch(e => e.message)
expect(errorMessage).toBe('name=minWords;path=this;length=2')
})
}) // group
}) // group

0 comments on commit 0fe88d7

Please sign in to comment.