Skip to content

Commit

Permalink
⭐ new(interpolation): list formatting refactor and places/place featu…
Browse files Browse the repository at this point in the history
…re (#218) by @myst729

* fix: test case typo

* improvement(interpolation): enable array-like named values for list tokens

* feature(component): place and places prop for component interpolation

* docs(formatting): enable array-like object values for list formatting

* docs(component): named formatting with place/places
  • Loading branch information
myst729 authored and kazupon committed Aug 28, 2017
1 parent ccf4c0a commit 0f0f3ff
Show file tree
Hide file tree
Showing 7 changed files with 251 additions and 37 deletions.
12 changes: 12 additions & 0 deletions gitbook/en/formatting.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ Output the below:
<p>hello world</p>
```

List formatting also accepts array-like object:

```html
<p>{{ $t('message.hello', {'0': 'hello'}) }}</p>
```

Output the below:

```html
<p>hello world</p>
```

## Support ruby on rails i18n format

Locale messages the below:
Expand Down
79 changes: 78 additions & 1 deletion gitbook/en/interpolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,81 @@ About the above example, see the [example](https://github.com/kazupon/vue-i18n/t

The children of `i18n` functional component is interpolated with locale message of `path` prop. In the above example, `<a :href="url" target="_blank">{{ $t('tos') }}</a>` is interplated with `term` locale message.

The component interpolations follows the **list formatting**. The named formatting is not support. The children of `i18n` functional component is interpolated with order of list formatting.
In above example, the component interpolation follows the **list formatting**. The children of `i18n` functional component are interpolated by their orders of appearance.

> :warning: NOTE: In `i18n` component, text content consists of only white spaces will be omitted.
Named formatting is supported with the help of `place` attribute. For example:

```html
<div id="app">
<!-- ... -->
<i18n path="info" tag="p">
<span place="limit">{{ changeLimit }}</span>
<a place="action" :href="changeUrl">{{ $t('change') }}</a>
</i18n>
<!-- ... -->
</div>
```

```javascript
const messages = {
en: {
info: 'You can {action} until {limit} minutes from departure.',
change: 'change your flight',
refund: 'refund the ticket'
}
}

const i18n = new VueI18n({
locale: 'en',
messages
})
new Vue({
i18n,
data: {
changeUrl: '/change',
refundUrl: '/refund',
changeLimit: 15,
refundLimit: 30
}
}).$mount('#app')
```

Outputs:

```html
<div id="app">
<!-- ... -->
<p>
You can <a href="/change">change your flight</a> until <span>15</span> minutes from departure.
</p>
<!-- ... -->
</div>
```

> :warning: NOTE: To use named formatting, all children of `i18n` component must have `place` attribute set. Otherwise it will fallback to list formatting.
If you still want to interpolate text content in named formatting, you could define `places` property on `i18n` component. For example:

```html
<div id="app">
<!-- ... -->
<i18n path="info" tag="p" :places="{ limit: refundLimit }">
<a place="action" :href="refundUrl">{{ $t('refund') }}</a>
</i18n>
<!-- ... -->
</div>
```

Outputs:

```html
<div id="app">
<!-- ... -->
<p>
You can <a href="/refund">refund your ticket</a> until 30 minutes from departure.
</p>
<!-- ... -->
</div>
```
47 changes: 41 additions & 6 deletions src/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,18 @@ export default {
},
locale: {
type: String
},
places: {
type: [Array, Object]
}
},
render (h: Function, { props, data, children, parent }: Object) {
const i18n = parent.$i18n

children = (children || []).filter(child => {
return child.tag || (child.text = child.text.trim())
})

if (!i18n) {
if (process.env.NODE_ENV !== 'production') {
warn('Cannot find VueI18n instance!')
Expand All @@ -30,14 +38,41 @@ export default {
const path: Path = props.path
const locale: ?Locale = props.locale

const params: Array<any> = []
locale && params.push(locale)
children.forEach(child => {
if (child.tag || child.text.trim()) {
params.push(child)
const params: Object = {}
const places: Array<any> | Object = props.places || {}

const hasPlaces: boolean = Array.isArray(places)
? places.length > 0
: Object.keys(places).length > 0

const everyPlace: boolean = children.every(child => {
if (child.data && child.data.attrs) {
const place = child.data.attrs.place
return (typeof place !== 'undefined') && place !== ''
}
})

return h(props.tag, data, i18n.i(path, ...params))
if (hasPlaces && children.length > 0 && !everyPlace) {
warn('If places prop is set, all child elements must have place prop set.')
}

if (Array.isArray(places)) {
places.forEach((el, i) => {
params[i] = el
})
} else {
Object.keys(places).forEach(key => {
params[key] = places[key]
})
}

children.forEach((child, i: number) => {
const key: string = everyPlace
? `${child.data.attrs.place}`
: `${i}`
params[key] = child
})

return h(props.tag, data, i18n.i(path, locale, params))
}
}
8 changes: 1 addition & 7 deletions src/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,7 @@ export function compile (tokens: Array<Token>, values: Object | Array<any>): Arr
compiled.push(token.value)
break
case 'list':
if (mode === 'list') {
compiled.push(values[parseInt(token.value, 10)])
} else {
if (process.env.NODE_ENV !== 'production') {
warn(`Type of token '${token.type}' and format of value '${mode}' don't match!`)
}
}
compiled.push(values[parseInt(token.value, 10)])
break
case 'named':
if (mode === 'named') {
Expand Down
20 changes: 6 additions & 14 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,37 +321,29 @@ export default class VueI18n {
return this._t(key, this.locale, this._getMessages(), null, ...values)
}

_i (key: Path, locale: Locale, messages: LocaleMessages, host: any, ...values: any): any {
_i (key: Path, locale: Locale, messages: LocaleMessages, host: any, values: Object): any {
const ret: any =
this._translate(messages, locale, this.fallbackLocale, key, host, 'raw', values)
if (this._isFallbackRoot(ret)) {
if (process.env.NODE_ENV !== 'production' && !this._silentTranslationWarn) {
warn(`Fall back to interpolate the keypath '${key}' with root locale.`)
}
if (!this._root) { throw Error('unexpected error') }
return this._root.i(key, ...values)
return this._root.i(key, locale, values)
} else {
return this._warnDefault(locale, key, ret, host)
}
}

i (key: Path, ...values: any): TranslateResult {
i (key: Path, locale: Locale, values: Object): TranslateResult {
/* istanbul ignore if */
if (!key) { return '' }

let locale: Locale = this.locale
let index: number = 0
if (typeof values[0] === 'string') {
locale = values[0]
index = 1
}

const params: Array<any> = []
for (let i = index; i < values.length; i++) {
params.push(values[i])
if (typeof locale !== 'string') {
locale = this.locale
}

return this._i(key, locale, this._getMessages(), null, ...params)
return this._i(key, locale, this._getMessages(), null, values)
}

_tc (
Expand Down
20 changes: 15 additions & 5 deletions test/unit/format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,27 @@ describe('compile', () => {
})
})

describe('list token with named value', () => {
it('should be compiled', () => {
const tokens = parse('name: {0}, age: {1}') // list tokens
const compiled = compile(tokens, { '0': 'kazupon', '1': '0x20' }) // named values
assert(compiled.length === 4)
assert.equal(compiled[0], 'name: ')
assert.equal(compiled[1], 'kazupon')
assert.equal(compiled[2], ', age: ')
assert.equal(compiled[3], '0x20')
})
})

describe('unmatch values mode', () => {
it('should be warned', () => {
const spy = sinon.spy(console, 'warn')

const tokens1 = parse('name: {0}, age: {1}') // list tokens
compile(tokens1, { name: 'kazupon', age: '0x20' }) // named values
const tokens2 = parse('name: {name}, age: {age}') // named tokens
compile(tokens2, ['kazupon', '0x20']) // list values
const tokens = parse('name: {name}, age: {age}') // named tokens
compile(tokens, ['kazupon', '0x20']) // list values

assert(spy.notCalled === false)
assert(spy.callCount === 4)
assert(spy.callCount === 2)
spy.restore()
})
})
Expand Down
102 changes: 98 additions & 4 deletions test/unit/interpolation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import Component from '../../src/component'
const messages = {
en: {
text: 'one: {0}',
premitive: 'one: {0}, two: {1}',
primitive: 'one: {0}, two: {1}',
component: 'element: {0}, component: {1}',
link: '@:premitive',
mixed: 'text: {x}, component: {y}',
link: '@:primitive',
term: 'I accept xxx {0}.',
tos: 'Term of service',
fallback: 'fallback from {0}'
Expand Down Expand Up @@ -60,13 +61,13 @@ describe('component interpolation', () => {
})
})

describe('premitive nodes', () => {
describe('primitive nodes', () => {
it('should be interpolated', done => {
const el = document.createElement('div')
const vm = new Vue({
i18n,
render (h) {
return h('i18n', { props: { path: 'premitive' } }, [
return h('i18n', { props: { path: 'primitive' } }, [
h('p', ['1']),
h('p', ['2'])
])
Expand Down Expand Up @@ -97,6 +98,99 @@ describe('component interpolation', () => {
})
})

describe('places prop', () => {
it('should be interpolated', done => {
const el = document.createElement('div')
const vm = new Vue({
i18n,
render (h) {
return h('i18n', { props: { path: 'text', places: [1] } })
}
}).$mount(el)
nextTick(() => {
assert.equal(vm.$el.textContent, 'one: 1')
}).then(done)
})
})

describe('place prop on all children', () => {
it('should be interpolated', done => {
const el = document.createElement('div')
const vm = new Vue({
i18n,
components,
render (h) {
return h('i18n', { props: { path: 'component' } }, [
h('p', { props: { place: 0 } }, ['1']),
h('comp', { props: { place: 1, msg: 'foo' } })
])
}
}).$mount(el)
nextTick(() => {
assert.equal(vm.$el.innerHTML, 'element: <p>1</p>, component: <p>foo</p>')
}).then(done)
})
})

describe('place prop on some children', () => {
it('should be interpolated', done => {
const el = document.createElement('div')
const vm = new Vue({
i18n,
components,
render (h) {
return h('i18n', { props: { path: 'component' } }, [
h('p', { props: { place: 1 } }, ['1']),
h('comp', { props: { msg: 'foo' } })
])
}
}).$mount(el)
nextTick(() => {
assert.equal(vm.$el.innerHTML, 'element: <p>1</p>, component: <p>foo</p>')
}).then(done)
})
})

describe('places and place mixed', () => {
it('should be interpolated', done => {
const el = document.createElement('div')
const vm = new Vue({
i18n,
components,
render (h) {
return h('i18n', { props: { path: 'mixed', places: { 'x': 'foo' } } }, [
h('comp', { props: { msg: 'bar' }, attrs: { place: 'y' } })
])
}
}).$mount(el)
nextTick(() => {
assert.equal(vm.$el.innerHTML, 'text: foo, component: <p place="y">bar</p>')
}).then(done)
})
})

describe('places set, place not set on all children', () => {
it('should be warned', done => {
const spy = sinon.spy(console, 'warn')
const el = document.createElement('div')
const vm = new Vue({
i18n,
components,
render (h) {
return h('i18n', { props: { path: 'mixed', places: { 'x': 'foo' } } }, [
h('comp', { props: { msg: 'bar' } })
])
}
}).$mount(el)
nextTick(() => {
assert.equal(vm.$el.innerHTML, 'text: foo, component: ')
assert(spy.notCalled === false)
assert(spy.callCount === 1)
spy.restore()
}).then(done)
})
})

describe('fallback', () => {
it('should be interpolated', done => {
const el = document.createElement('div')
Expand Down

0 comments on commit 0f0f3ff

Please sign in to comment.