From 8521973033531a27aa89d9b30d79641b688935d3 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 14 Jun 2023 16:07:54 +0300 Subject: [PATCH 1/7] Update vue versions --- scripts/swap-vue.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/swap-vue.mjs b/scripts/swap-vue.mjs index 9ef8b2a5..21028fcf 100644 --- a/scripts/swap-vue.mjs +++ b/scripts/swap-vue.mjs @@ -2,10 +2,10 @@ import { readFileSync, writeFileSync } from 'fs' import { execa } from 'execa' const vue3packages = { - 'vue': 'npm:vue@^3.2.47', + 'vue': 'npm:vue@^3.3.4', 'vue-2': 'npm:vue@^2.7.14', - 'vue-3': 'npm:vue@^3.2.47', - '@vue/compiler-sfc': '^3.2.47', + 'vue-3': 'npm:vue@^3.3.4', + '@vue/compiler-sfc': '^3.3.4', '@vue/test-utils': '^2.3.2', } From eb6f01f374cede0bc8308e3a32c50dd275fd07e7 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 14 Jun 2023 16:10:27 +0300 Subject: [PATCH 2/7] Allow to not wrap translation in a html element --- src/index.ts | 21 +++--- src/types/index.d.ts | 18 ++++- src/util/options.ts | 9 ++- src/vue/component.ts | 158 ++++++++++++++++++++++--------------------- 4 files changed, 112 insertions(+), 94 deletions(-) diff --git a/src/index.ts b/src/index.ts index cc2c49c5..56fb2b29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import type { InstallFunction, Vue, Vue2, Vue3, Vue3Component } from './types/ty import type { TranslationWithAttrs } from './TranslationContext' import { TranslationContext } from './TranslationContext' import { createVue2Directive, createVue3Directive } from './vue/directive' -import component from './vue/component' +import { createComponent } from './vue/component' import { getContext } from './getContext' import { RootContextSymbol } from './symbols' import { resolveOptions } from './util/options' @@ -51,30 +51,25 @@ export function createFluentVue(options: FluentVueOptions): FluentVue { formatWithAttrs: rootContext.formatWithAttrs.bind(rootContext), install(vue) { - const globalFormatName = options.globals?.functions?.format || '$t' - const globalFormatAttrsName = options.globals?.functions?.formatAttrs || '$ta' - const directiveName = options.globals?.directive || 't' - const componentName = options.globals?.component || 'i18n' - if (isVue3) { const vue3 = vue as Vue3 vue3.provide(RootContextSymbol, rootContext) - vue3.config.globalProperties[globalFormatName] = function ( + vue3.config.globalProperties[resolvedOptions.globalFormatName] = function ( key: string, value?: Record, ) { return getContext(rootContext, this as Vue3Component).format(key, value) } - vue3.config.globalProperties[globalFormatAttrsName] = function ( + vue3.config.globalProperties[resolvedOptions.globalFormatAttrsName] = function ( key: string, value?: Record, ) { return getContext(rootContext, this as Vue3Component).formatAttrs(key, value) } - vue3.directive(directiveName, createVue3Directive(rootContext)) + vue3.directive(resolvedOptions.directiveName, createVue3Directive(rootContext)) } else { const vue2 = vue as Vue2 @@ -87,17 +82,17 @@ export function createFluentVue(options: FluentVueOptions): FluentVue { }, }) - vue2.prototype[globalFormatName] = function (key: string, value?: Record) { + vue2.prototype[resolvedOptions.globalFormatName] = function (key: string, value?: Record) { return getContext(rootContext, this).format(key, value) } - vue2.prototype[globalFormatAttrsName] = function (key: string, value?: Record) { + vue2.prototype[resolvedOptions.globalFormatAttrsName] = function (key: string, value?: Record) { return getContext(rootContext, this).formatAttrs(key, value) } - vue2.directive(directiveName, createVue2Directive(rootContext)) + vue2.directive(resolvedOptions.directiveName, createVue2Directive(rootContext)) } - (vue as Vue).component(componentName, component) + (vue as Vue).component(resolvedOptions.componentName, createComponent(resolvedOptions)) }, } } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 750d0685..74e70f49 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -20,9 +20,25 @@ export interface FluentVueOptions { }, component?: string directive?: string - }} + } + + /** + * Tag name for the component. + * Set to `false` to disable wrapping the translation in a tag. + * @default 'span' + */ + tag?: string | false +} export interface TranslationContextOptions { warnMissing: (key: string) => void parseMarkup: (markup: string) => SimpleNode[] } + +export interface ResolvedOptions extends TranslationContextOptions { + globalFormatName: string + globalFormatAttrsName: string + directiveName: string + componentName: string + tag: string | false +} diff --git a/src/util/options.ts b/src/util/options.ts index 75034dc0..0c84ed37 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -1,4 +1,4 @@ -import type { FluentVueOptions, SimpleNode, TranslationContextOptions } from 'src/types' +import type { FluentVueOptions, ResolvedOptions, SimpleNode } from 'src/types' import { assert, warn } from './warn' @@ -25,9 +25,14 @@ function getWarnMissing(options: FluentVueOptions) { return options.warnMissing } -export function resolveOptions(options: FluentVueOptions): TranslationContextOptions { +export function resolveOptions(options: FluentVueOptions): ResolvedOptions { return { warnMissing: getWarnMissing(options), parseMarkup: options.parseMarkup ?? defaultMarkupParser, + globalFormatName: options.globals?.functions?.format ?? '$t', + globalFormatAttrsName: options.globals?.functions?.formatAttrs ?? '$ta', + directiveName: options.globals?.directive ?? 't', + componentName: options.globals?.component ?? 'i18n', + tag: options.tag ?? 'span', } } diff --git a/src/vue/component.ts b/src/vue/component.ts index 9cdffe48..547e1963 100644 --- a/src/vue/component.ts +++ b/src/vue/component.ts @@ -1,5 +1,5 @@ -import { computed, defineComponent, getCurrentInstance, h, inject } from 'vue-demi' -import type { SimpleNode } from 'src/types' +import { type PropType, computed, defineComponent, getCurrentInstance, h, inject } from 'vue-demi' +import type { ResolvedOptions, SimpleNode } from 'src/types' import type { VueComponent } from 'src/types/typesCompat' import { camelize } from '../util/camelize' @@ -23,86 +23,88 @@ function getParentWithFluent( // &, &, &. const reMarkup = /<|&#?\w+;/ -export default defineComponent({ - name: 'i18n', - props: { - path: { type: String, required: true }, - tag: { type: String, default: 'span' }, - args: { type: Object, default: () => ({}) }, - html: { type: Boolean, default: false }, - }, - setup(props, { slots, attrs }) { - const rootContext = inject(RootContextSymbol) - assert(rootContext != null, 'i18n component used without installing plugin') - const instance = getCurrentInstance() - const parent = getParentWithFluent(instance?.proxy) - const fluent = getContext(rootContext, parent) - - const translation = computed(() => { - const fluentParams = Object.assign( - {}, - props.args, - // Create fake translation parameters for each slot. - // Later, we'll replace the parameters with the actual slot - ...Object.keys(slots).map(key => ({ - [key]: `\uFFFF\uFFFE${key}\uFFFF`, - })), - ) - - const result = fluent.formatWithAttrs(props.path, fluentParams) - - const camelizedAttrs = Object.fromEntries( - Object.entries(result.attributes).map(([key, value]) => [camelize(key), value]), - ) - - return { - value: result.value, - attributes: camelizedAttrs, - } - }) - - const insertSlots = (text: string | null) => { - return text?.split('\uFFFF') - .map(text => - text.startsWith('\uFFFE') - ? slots[text.replace('\uFFFE', '')]!(translation.value.attributes) - : text, +export function createComponent(options: ResolvedOptions) { + return defineComponent({ + name: options.componentName, + props: { + path: { type: String, required: true }, + tag: { type: [String, Boolean] as PropType, default: options.tag }, + args: { type: Object, default: () => ({}) }, + html: { type: Boolean, default: false }, + }, + setup(props, { slots, attrs }) { + const rootContext = inject(RootContextSymbol) + assert(rootContext != null, 'i18n component used without installing plugin') + const instance = getCurrentInstance() + const parent = getParentWithFluent(instance?.proxy) + const fluent = getContext(rootContext, parent) + + const translation = computed(() => { + const fluentParams = Object.assign( + {}, + props.args, + // Create fake translation parameters for each slot. + // Later, we'll replace the parameters with the actual slot + ...Object.keys(slots).map(key => ({ + [key]: `\uFFFF\uFFFE${key}\uFFFF`, + })), ) - } - // No way to type this properly, so we'll just use `any` for now. - const processNode = (node: SimpleNode): any => { - if (node.nodeType === 3) { // Node.TEXT_NODE - return insertSlots(node.nodeValue) - } - else if (node.nodeType === 1) { // Node.ELEMENT_NODE - const el = node as Element - - return h( - el.nodeName.toLowerCase(), - { - ...Object.fromEntries( - Array.from(el.attributes).map(attr => [attr.name, attr.value]), - ), - }, - Array.from(el.childNodes).map(node => processNode(node))) + const result = fluent.formatWithAttrs(props.path, fluentParams) + + const camelizedAttrs = Object.fromEntries( + Object.entries(result.attributes).map(([key, value]) => [camelize(key), value]), + ) + + return { + value: result.value, + attributes: camelizedAttrs, + } + }) + + const insertSlots = (text: string | null) => { + return text?.split('\uFFFF') + .map(text => + text.startsWith('\uFFFE') + ? slots[text.replace('\uFFFE', '')]!(translation.value.attributes) + : text, + ) } - // Ignore other node types for now. - warn(`Unsupported node type: ${(node as any).nodeType}. If you need support for it, please, create an issue in fluent-vue repository.`) - return [] - } + // No way to type this properly, so we'll just use `any` for now. + const processNode = (node: SimpleNode): any => { + if (node.nodeType === 3) { // Node.TEXT_NODE + return insertSlots(node.nodeValue) + } + else if (node.nodeType === 1) { // Node.ELEMENT_NODE + const el = node as Element + + return h( + el.nodeName.toLowerCase(), + { + ...Object.fromEntries( + Array.from(el.attributes).map(attr => [attr.name, attr.value]), + ), + }, + Array.from(el.childNodes).map(node => processNode(node))) + } + + // Ignore other node types for now. + warn(`Unsupported node type: ${(node as any).nodeType}. If you need support for it, please, create an issue in fluent-vue repository.`) + return [] + } - const children = computed(() => { - // If the message value doesn't contain any markup nor any HTML entities, return it as-is. - if (!props.html || !reMarkup.test(translation.value.value)) - return insertSlots(translation.value.value) + const children = computed(() => { + // If the message value doesn't contain any markup nor any HTML entities, return it as-is. + if (!props.html || !reMarkup.test(translation.value.value)) + return insertSlots(translation.value.value) - // Otherwise, parse the message value as HTML and convert it to an array of VNodes. - const nodes = fluent.options.parseMarkup(translation.value.value) - return nodes.map(processNode) - }) + // Otherwise, parse the message value as HTML and convert it to an array of VNodes. + const nodes = fluent.options.parseMarkup(translation.value.value) + return nodes.map(processNode) + }) - return () => h(props.tag, { ...attrs }, children.value) - }, -}) + return () => props.tag === false ? children.value : h(props.tag, { ...attrs }, children.value) + }, + }) +} From f0af2d4f861e86116bef651f220d3765850a9470 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 14 Jun 2023 16:15:57 +0300 Subject: [PATCH 3/7] Add more convenient prop --- src/vue/component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vue/component.ts b/src/vue/component.ts index 547e1963..c143a2ab 100644 --- a/src/vue/component.ts +++ b/src/vue/component.ts @@ -31,6 +31,7 @@ export function createComponent(options: ResolvedOptions) { tag: { type: [String, Boolean] as PropType, default: options.tag }, args: { type: Object, default: () => ({}) }, html: { type: Boolean, default: false }, + noTag: { type: Boolean, default: false }, }, setup(props, { slots, attrs }) { const rootContext = inject(RootContextSymbol) @@ -104,7 +105,7 @@ export function createComponent(options: ResolvedOptions) { return nodes.map(processNode) }) - return () => props.tag === false ? children.value : h(props.tag, { ...attrs }, children.value) + return () => props.tag === false || props.noTag ? children.value : h(props.tag, { ...attrs }, children.value) }, }) } From 1540adfa48df47ad27242b792c8e630c6f534025 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 14 Jun 2023 16:17:03 +0300 Subject: [PATCH 4/7] Add tests --- __tests__/vue/component.spec.ts | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/__tests__/vue/component.spec.ts b/__tests__/vue/component.spec.ts index 77e44c30..828e7d15 100644 --- a/__tests__/vue/component.spec.ts +++ b/__tests__/vue/component.spec.ts @@ -318,4 +318,54 @@ describe('component', () => { expect(mounted.get('strong')).toBeTruthy() expect(mounted.html()).toEqual('Test Inner strong ') }) + + it('can work with tag=false', async () => { + // Arrange + bundle.addResource( + new FluentResource(ftl` + key = Hello {$name} + `), + ) + + const component = { + data() { + return { + name: 'John', + } + }, + template: ` + `, + } + + // Act + const mounted = mountWithFluent(fluent, component) + + // Assert + expect(mounted.html()).toEqual('Hello \u{2068}John\u{2069}') + }) + + it('can work with no-tag', async () => { + // Arrange + bundle.addResource( + new FluentResource(ftl` + key = Hello {$name} + `), + ) + + const component = { + data() { + return { + name: 'John', + } + }, + template: ` + `, + } + + // Act + const mounted = mountWithFluent(fluent, component) + + // Assert + expect(mounted.html()).toEqual('Hello \u{2068}John\u{2069}') + }) }) From fa1e0e17701e56ac4467adda089f7d6760c60d36 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 14 Jun 2023 16:29:43 +0300 Subject: [PATCH 5/7] Add a warning for incorrect usage with Vue 2 --- __tests__/vue/component.spec.ts | 45 ++++++++++++++++++++++++++++----- package.json | 2 +- src/vue/component.ts | 13 +++++++++- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/__tests__/vue/component.spec.ts b/__tests__/vue/component.spec.ts index 828e7d15..f88f3b32 100644 --- a/__tests__/vue/component.spec.ts +++ b/__tests__/vue/component.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { isVue2, isVue3 } from 'vue-demi' import { FluentBundle, FluentResource } from '@fluent/bundle' import ftl from '@fluent/dedent' @@ -319,12 +320,12 @@ describe('component', () => { expect(mounted.html()).toEqual('Test Inner strong ') }) - it('can work with tag=false', async () => { + it.runIf(isVue3)('can work with tag=false', async () => { // Arrange bundle.addResource( new FluentResource(ftl` - key = Hello {$name} - `), + key = Hello {$name} + `), ) const component = { @@ -344,12 +345,12 @@ describe('component', () => { expect(mounted.html()).toEqual('Hello \u{2068}John\u{2069}') }) - it('can work with no-tag', async () => { + it.runIf(isVue3)('can work with no-tag', async () => { // Arrange bundle.addResource( new FluentResource(ftl` - key = Hello {$name} - `), + key = Hello {$name} + `), ) const component = { @@ -368,4 +369,36 @@ describe('component', () => { // Assert expect(mounted.html()).toEqual('Hello \u{2068}John\u{2069}') }) + + it.runIf(isVue2)('warns when used with tag=false', async () => { + // Arrange + bundle.addResource( + new FluentResource(ftl` + key = Hello {$name} + `), + ) + + const component = { + data() { + return { + name: 'John', + } + }, + template: ` + `, + } + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Act + const mounted = mountWithFluent(fluent, component) + + // Assert + expect(mounted.html()).toEqual('') + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith('[fluent-vue] Vue 2 requires a root element when rendering components. Please, use `tag` prop to specify the root element.') + + // Cleanup + warn.mockRestore() + }) }) diff --git a/package.json b/package.json index 04dba59d..22ec84c8 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "ls-lint": "ls-lint", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "node scripts/swap-vue.mjs 3 && vitest run", - "test:watch": "node scripts/swap-vue.mjs 3 && vitest", + "test:watch": "vitest", "test:2": "node scripts/swap-vue.mjs 2 && vitest run", "test:3": "node scripts/swap-vue.mjs 3 && vitest run", "prepare": "husky install", diff --git a/src/vue/component.ts b/src/vue/component.ts index c143a2ab..83120f9d 100644 --- a/src/vue/component.ts +++ b/src/vue/component.ts @@ -1,4 +1,12 @@ -import { type PropType, computed, defineComponent, getCurrentInstance, h, inject } from 'vue-demi' +import { + type PropType, + computed, + defineComponent, + getCurrentInstance, + h, + inject, + isVue2, +} from 'vue-demi' import type { ResolvedOptions, SimpleNode } from 'src/types' import type { VueComponent } from 'src/types/typesCompat' @@ -105,6 +113,9 @@ export function createComponent(options: ResolvedOptions) { return nodes.map(processNode) }) + if (isVue2 && (props.tag === false || props.noTag)) + warn('Vue 2 requires a root element when rendering components. Please, use `tag` prop to specify the root element.') + return () => props.tag === false || props.noTag ? children.value : h(props.tag, { ...attrs }, children.value) }, }) From 1ce011f7c0d403d9f9b8ba4acab5555364739444 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 14 Jun 2023 16:41:01 +0300 Subject: [PATCH 6/7] Rename config option --- src/types/index.d.ts | 6 +++--- src/util/options.ts | 2 +- src/vue/component.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 74e70f49..e2470782 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -23,11 +23,11 @@ export interface FluentVueOptions { } /** - * Tag name for the component. + * Tag name used in the `i18n` component. * Set to `false` to disable wrapping the translation in a tag. * @default 'span' */ - tag?: string | false + componentTag?: string | false } export interface TranslationContextOptions { @@ -40,5 +40,5 @@ export interface ResolvedOptions extends TranslationContextOptions { globalFormatAttrsName: string directiveName: string componentName: string - tag: string | false + componentTag: string | false } diff --git a/src/util/options.ts b/src/util/options.ts index 0c84ed37..4473267a 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -33,6 +33,6 @@ export function resolveOptions(options: FluentVueOptions): ResolvedOptions { globalFormatAttrsName: options.globals?.functions?.formatAttrs ?? '$ta', directiveName: options.globals?.directive ?? 't', componentName: options.globals?.component ?? 'i18n', - tag: options.tag ?? 'span', + componentTag: options.componentTag ?? 'span', } } diff --git a/src/vue/component.ts b/src/vue/component.ts index 83120f9d..5b8f6c3a 100644 --- a/src/vue/component.ts +++ b/src/vue/component.ts @@ -36,7 +36,7 @@ export function createComponent(options: ResolvedOptions) { name: options.componentName, props: { path: { type: String, required: true }, - tag: { type: [String, Boolean] as PropType, default: options.tag }, + tag: { type: [String, Boolean] as PropType, default: options.componentTag }, args: { type: Object, default: () => ({}) }, html: { type: Boolean, default: false }, noTag: { type: Boolean, default: false }, From 43e693461c4dc8efb8a2c13758956426fd48c8ff Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Wed, 14 Jun 2023 19:25:50 +0300 Subject: [PATCH 7/7] Remove need to inject root context into the component --- src/index.ts | 2 +- src/vue/component.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 56fb2b29..c065894e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,7 +92,7 @@ export function createFluentVue(options: FluentVueOptions): FluentVue { vue2.directive(resolvedOptions.directiveName, createVue2Directive(rootContext)) } - (vue as Vue).component(resolvedOptions.componentName, createComponent(resolvedOptions)) + (vue as Vue).component(resolvedOptions.componentName, createComponent(resolvedOptions, rootContext)) }, } } diff --git a/src/vue/component.ts b/src/vue/component.ts index 5b8f6c3a..ec7c37bf 100644 --- a/src/vue/component.ts +++ b/src/vue/component.ts @@ -4,7 +4,6 @@ import { defineComponent, getCurrentInstance, h, - inject, isVue2, } from 'vue-demi' import type { ResolvedOptions, SimpleNode } from 'src/types' @@ -12,8 +11,8 @@ import type { VueComponent } from 'src/types/typesCompat' import { camelize } from '../util/camelize' import { getContext } from '../getContext' -import { RootContextSymbol } from '../symbols' -import { assert, warn } from '../util/warn' +import { warn } from '../util/warn' +import type { TranslationContext } from '../TranslationContext' function getParentWithFluent( instance: VueComponent | null | undefined, @@ -31,7 +30,7 @@ function getParentWithFluent( // &, &, &. const reMarkup = /<|&#?\w+;/ -export function createComponent(options: ResolvedOptions) { +export function createComponent(options: ResolvedOptions, rootContext: TranslationContext) { return defineComponent({ name: options.componentName, props: { @@ -42,8 +41,6 @@ export function createComponent(options: ResolvedOptions) { noTag: { type: Boolean, default: false }, }, setup(props, { slots, attrs }) { - const rootContext = inject(RootContextSymbol) - assert(rootContext != null, 'i18n component used without installing plugin') const instance = getCurrentInstance() const parent = getParentWithFluent(instance?.proxy) const fluent = getContext(rootContext, parent)