diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1fe981cf..2ce7e3b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,6 +53,8 @@ jobs: - name: Lint run: pnpm lint + - name: Type check + run: pnpm typecheck test-examples: runs-on: ubuntu-latest diff --git a/__tests__/vue/component.spec.ts b/__tests__/vue/component.spec.ts index 532524e7..77e44c30 100644 --- a/__tests__/vue/component.spec.ts +++ b/__tests__/vue/component.spec.ts @@ -253,4 +253,69 @@ describe('component', () => { // Assert expect(mounted.html()).toEqual('Hello \u{2068}Alice\u{2069} \u{2068}Inner text\u{2069} test') }) + + it('supports html #760', async () => { + // Arrange + bundle.addResource( + new FluentResource(ftl` + general-pages-terms = Terms + + general-register-info = Register here.
But respect our terms (see {$showTermsModalSpan}). + .general-pages-terms = { general-pages-terms } + `), + ) + + const click = vi.fn() + + const component = { + methods: { + showModal(param: string) { + click(param) + }, + }, + template: ` + + + `, + } + + // Act + const mounted = mountWithFluent(fluent, component) + + // Assert + expect(mounted.get('br')).toBeTruthy() + expect(mounted.get('strong')).toBeTruthy() + expect(mounted.get('span.underline')).toBeTruthy() + expect(mounted.html()).toEqual('Register here.
But respect our terms (see \u{2068}Terms\u{2069}).
') + + // Just in case check if the click handler is working + // Act + await mounted.find('.underline').trigger('click') + + // Assert + expect(click).toHaveBeenCalledWith('terms') + }) + + it('supports nested html', async () => { + // Arrange + bundle.addResource( + new FluentResource(ftl` + key = Test Inner strong + `), + ) + + const component = { + template: '', + } + + // Act + const mounted = mountWithFluent(fluent, component) + + // Assert + expect(mounted.get('span')).toBeTruthy() + expect(mounted.get('strong')).toBeTruthy() + expect(mounted.html()).toEqual('Test Inner strong ') + }) }) diff --git a/__tests__/vue/noDom.spec.ts b/__tests__/vue/noDom.spec.ts new file mode 100644 index 00000000..308899b4 --- /dev/null +++ b/__tests__/vue/noDom.spec.ts @@ -0,0 +1,127 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' + +import { FluentBundle, FluentResource } from '@fluent/bundle' +import ftl from '@fluent/dedent' + +import { DOMParser as HappyDomParser } from 'happy-dom' + +import { createFluentVue } from '../../src' +import { mountWithFluent } from '../utils' + +describe('component html support', () => { + beforeAll(() => { + // @ts-expect-error - we're testing the error case + // eslint-disable-next-line no-global-assign + DOMParser = undefined + }) + + it('throws if no access to DOMParser', () => { + // Arrange + const bundle = new FluentBundle('en-US') + bundle.addResource( + new FluentResource(ftl` + key = Hello {$name}
How are you? + `), + ) + + const fluent = createFluentVue({ + bundles: [bundle], + }) + + const component = { + data() { + return { + name: 'John', + } + }, + template: ` + `, + } + + // Act + const mount = () => mountWithFluent(fluent, component) + + // Assert + expect(mount).toThrow('[fluent-vue] DOMParser is not available. Please provide a custom parseMarkup function.') + }) + + it('works with custom parseMarkup function', () => { + // Arrange + const bundle = new FluentBundle('en-US') + bundle.addResource( + new FluentResource(ftl` + key = Hello {$name}
How are you? + `), + ) + + const fluent = createFluentVue({ + bundles: [bundle], + parseMarkup: (markup: string) => { + const parser = new HappyDomParser() + const doc = parser.parseFromString(markup, 'text/html') + const nodes = Array.from(doc.body.childNodes) + + return nodes + }, + }) + + const component = { + data() { + return { + name: 'John', + } + }, + template: ` + `, + } + + // Act + const mounted = mountWithFluent(fluent, component) + + // Assert + expect(mounted.html()).toMatchInlineSnapshot('"Hello \u{2068}John\u{2069}
How are you?
"') + }) + + it('warn about not support markup', () => { + // Arrange + const bundle = new FluentBundle('en-US') + bundle.addResource( + new FluentResource(ftl` + key = Hello {$name}
How are you? + `), + ) + + const fluent = createFluentVue({ + bundles: [bundle], + parseMarkup: (markup: string) => { + const parser = new HappyDomParser() + const doc = parser.parseFromString(markup, 'text/html') + const nodes = Array.from(doc.body.childNodes) + + return nodes + }, + }) + + const component = { + data() { + return { + name: 'John', + } + }, + template: ` + `, + } + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Act + const mounted = mountWithFluent(fluent, component) + + // Assert + expect(mounted.html()).toMatchInlineSnapshot('"Hello \u{2068}John\u{2069}
How are you?
"') + expect(warn).toHaveBeenCalledWith('[fluent-vue] Unsupported node type: 8. If you need support for it, please, create an issue in fluent-vue repository.') + + // Cleanup + warn.mockRestore() + }) +}) diff --git a/package.json b/package.json index 84a2a0ec..6adc8667 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "esbuild-plugin-globals": "^0.1.1", "eslint": "^8.24.0", "execa": "^6.1.0", - "happy-dom": "^7.2.0", + "happy-dom": "^7.6.0", "husky": "^8.0.1", "lint-staged": "^13.0.3", "release-it": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39ba4354..67a4ef34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ specifiers: esbuild-plugin-globals: ^0.1.1 eslint: ^8.24.0 execa: ^6.1.0 - happy-dom: ^7.2.0 + happy-dom: ^7.6.0 husky: ^8.0.1 lint-staged: ^13.0.3 release-it: '*' @@ -41,13 +41,13 @@ devDependencies: '@ls-lint/ls-lint': 1.11.2 '@release-it-plugins/lerna-changelog': 5.0.0_release-it@14.13.1 '@types/node': 17.0.23 - '@vitest/coverage-istanbul': 0.24.3_happy-dom@7.2.0 + '@vitest/coverage-istanbul': 0.24.3_happy-dom@7.6.0 '@vue/compiler-sfc': 3.2.41 '@vue/test-utils': 2.1.0_vue@3.2.41 esbuild-plugin-globals: 0.1.1 eslint: 8.24.0 execa: 6.1.0 - happy-dom: 7.2.0 + happy-dom: 7.6.0 husky: 8.0.1 lint-staged: 13.0.3 release-it: 14.13.1 @@ -55,7 +55,7 @@ devDependencies: tsup: 6.2.3_typescript@4.8.4 typescript: 4.8.4 vite: 3.1.4 - vitest: 0.23.4_happy-dom@7.2.0 + vitest: 0.23.4_happy-dom@7.6.0 vue: 3.2.41 vue-2: /vue/2.7.13 vue-3: /vue/3.2.41 @@ -279,16 +279,15 @@ packages: /@babel/helper-string-parser/7.19.4: resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==} engines: {node: '>=6.9.0'} - dev: true /@babel/helper-validator-identifier/7.18.6: resolution: {integrity: sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==} engines: {node: '>=6.9.0'} + dev: true /@babel/helper-validator-identifier/7.19.1: resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} engines: {node: '>=6.9.0'} - dev: true /@babel/helper-validator-option/7.18.6: resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} @@ -310,25 +309,17 @@ packages: resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-validator-identifier': 7.18.6 + '@babel/helper-validator-identifier': 7.19.1 chalk: 2.4.2 js-tokens: 4.0.0 dev: true - /@babel/parser/7.18.8: - resolution: {integrity: sha512-RSKRfYX20dyH+elbJK2uqAkVyucL+xXzhqlMD5/ZXx+dAAwpyB7HsvnHe/ZUGOF+xLr5Wx9/JoXVTj6BQE2/oA==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.18.4 - /@babel/parser/7.19.6: resolution: {integrity: sha512-h1IUp81s2JYJ3mRkdxJgs4UvmSsRvDrx5ICSJbPvtWYv5i1nTBGcBpnog+89rAFMwvvru6E5NUHdBe01UeSzYA==} engines: {node: '>=6.0.0'} hasBin: true dependencies: '@babel/types': 7.19.4 - dev: true /@babel/template/7.18.10: resolution: {integrity: sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==} @@ -357,13 +348,6 @@ packages: - supports-color dev: true - /@babel/types/7.18.4: - resolution: {integrity: sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.18.6 - to-fast-properties: 2.0.0 - /@babel/types/7.19.4: resolution: {integrity: sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==} engines: {node: '>=6.9.0'} @@ -371,7 +355,6 @@ packages: '@babel/helper-string-parser': 7.19.4 '@babel/helper-validator-identifier': 7.19.1 to-fast-properties: 2.0.0 - dev: true /@esbuild/linux-loong64/0.15.6: resolution: {integrity: sha512-hqmVU2mUjH6J2ZivHphJ/Pdse2ZD+uGCHK0uvsiLDk/JnSedEVj77CiVUnbMKuU4tih1TZZL8tG9DExQg/GZsw==} @@ -722,7 +705,7 @@ packages: dev: true /@types/form-data/0.0.33: - resolution: {integrity: sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw==} + resolution: {integrity: sha1-yayFsqX9GENbjIXZ7LUObWyJP/g=} dependencies: '@types/node': 17.0.23 dev: true @@ -916,7 +899,7 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@vitest/coverage-istanbul/0.24.3_happy-dom@7.2.0: + /@vitest/coverage-istanbul/0.24.3_happy-dom@7.6.0: resolution: {integrity: sha512-cPQ5icP/TPih3qeZ29qA2zg1lxAM+wAUuh1ZkQJ75OK7am/8isqhP5QCCeGCLdFOakDsm4Ik8vHHfcDffuxW9Q==} dependencies: istanbul-lib-coverage: 3.2.0 @@ -925,7 +908,7 @@ packages: istanbul-lib-source-maps: 4.0.1 istanbul-reports: 3.1.5 test-exclude: 6.0.0 - vitest: 0.24.3_happy-dom@7.2.0 + vitest: 0.24.3_happy-dom@7.6.0 transitivePeerDependencies: - '@edge-runtime/vm' - '@vitest/browser' @@ -942,7 +925,7 @@ packages: /@vue/compiler-core/3.2.41: resolution: {integrity: sha512-oA4mH6SA78DT+96/nsi4p9DX97PHcNROxs51lYk7gb9Z4BPKQ3Mh+BLn6CQZBw857Iuhu28BfMSRHAlPvD4vlw==} dependencies: - '@babel/parser': 7.18.8 + '@babel/parser': 7.19.6 '@vue/shared': 3.2.41 estree-walker: 2.0.2 source-map: 0.6.1 @@ -956,7 +939,7 @@ packages: /@vue/compiler-sfc/2.7.13: resolution: {integrity: sha512-zzu2rLRZlgIU+OT3Atbr7Y6PG+LW4wVQpPfNRrGDH3dM9PsrcVfa+1pKb8bW467bGM3aDOvAnsYLWVpYIv3GRg==} dependencies: - '@babel/parser': 7.18.8 + '@babel/parser': 7.19.6 postcss: 8.4.16 source-map: 0.6.1 dev: true @@ -964,7 +947,7 @@ packages: /@vue/compiler-sfc/3.2.41: resolution: {integrity: sha512-+1P2m5kxOeaxVmJNXnBskAn3BenbTmbxBxWOtBq3mQTCokIreuMULFantBUclP0+KnzNCMOvcnKinqQZmiOF8w==} dependencies: - '@babel/parser': 7.18.8 + '@babel/parser': 7.19.6 '@vue/compiler-core': 3.2.41 '@vue/compiler-dom': 3.2.41 '@vue/compiler-ssr': 3.2.41 @@ -984,7 +967,7 @@ packages: /@vue/reactivity-transform/3.2.41: resolution: {integrity: sha512-mK5+BNMsL4hHi+IR3Ft/ho6Za+L3FA5j8WvreJ7XzHrqkPq8jtF/SMo7tuc9gHjLDwKZX1nP1JQOKo9IEAn54A==} dependencies: - '@babel/parser': 7.18.8 + '@babel/parser': 7.19.6 '@vue/compiler-core': 3.2.41 '@vue/shared': 3.2.41 estree-walker: 2.0.2 @@ -2909,8 +2892,8 @@ packages: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true - /happy-dom/7.2.0: - resolution: {integrity: sha512-xwJ/zFQx0/RNdW9FW1b5QJz4dCfo2fQlnYoxsDdwbh5ttIyTSDmdtnGPTbliTBfCvtUN71O3sVIvxf5YYWln0g==} + /happy-dom/7.6.0: + resolution: {integrity: sha512-QnNsiblZdyVDzW5ts6E7ub79JnabqHJeJgt+1WGNq9fSYqS/r/RzzTVXCZSDl6EVkipdwI48B4bgXAnMZPecIw==} dependencies: css.escape: 1.5.1 he: 1.2.0 @@ -3451,7 +3434,7 @@ packages: engines: {node: '>=8'} dependencies: '@babel/core': 7.19.6 - '@babel/parser': 7.18.8 + '@babel/parser': 7.19.6 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 semver: 6.3.0 @@ -5716,7 +5699,7 @@ packages: fsevents: 2.3.2 dev: true - /vitest/0.23.4_happy-dom@7.2.0: + /vitest/0.23.4_happy-dom@7.6.0: resolution: {integrity: sha512-iukBNWqQAv8EKDBUNntspLp9SfpaVFbmzmM0sNcnTxASQZMzRw3PsM6DMlsHiI+I6GeO5/sYDg3ecpC+SNFLrQ==} engines: {node: '>=v14.16.0'} hasBin: true @@ -5743,7 +5726,7 @@ packages: '@types/node': 17.0.23 chai: 4.3.6 debug: 4.3.4 - happy-dom: 7.2.0 + happy-dom: 7.6.0 local-pkg: 0.4.2 strip-literal: 0.4.2 tinybench: 2.2.1 @@ -5758,7 +5741,7 @@ packages: - terser dev: true - /vitest/0.24.3_happy-dom@7.2.0: + /vitest/0.24.3_happy-dom@7.6.0: resolution: {integrity: sha512-aM0auuPPgMSstWvr851hB74g/LKaKBzSxcG3da7ejfZbx08Y21JpZmbmDYrMTCGhVZKqTGwzcnLMwyfz2WzkhQ==} engines: {node: '>=v14.16.0'} hasBin: true @@ -5785,7 +5768,7 @@ packages: '@types/node': 17.0.23 chai: 4.3.6 debug: 4.3.4 - happy-dom: 7.2.0 + happy-dom: 7.6.0 local-pkg: 0.4.2 strip-literal: 0.4.2 tinybench: 2.3.1 diff --git a/src/TranslationContext.ts b/src/TranslationContext.ts index 4d3815ce..82005145 100644 --- a/src/TranslationContext.ts +++ b/src/TranslationContext.ts @@ -1,8 +1,9 @@ import type { Message, Pattern } from '@fluent/bundle/esm/ast' import type { FluentBundle, FluentVariable } from '@fluent/bundle' import type { Ref } from 'vue-demi' - import { mapBundleSync } from '@fluent/sequence' +import type { TranslationContextOptions } from './types' + import { warn } from './util/warn' export interface TranslationWithAttrs { @@ -14,7 +15,7 @@ export interface TranslationWithAttrs { export class TranslationContext { bundles: Ref> - constructor(bundles: Ref>, public warnMissing: (key: string) => void) { + constructor(bundles: Ref>, public options: TranslationContextOptions) { this.bundles = bundles } @@ -26,7 +27,7 @@ export class TranslationContext { const message = bundle?.getMessage(key) if (message === undefined) { - this.warnMissing(key) + this.options.warnMissing(key) return null } diff --git a/src/getContext.ts b/src/getContext.ts index 21660024..365f8a51 100644 --- a/src/getContext.ts +++ b/src/getContext.ts @@ -48,7 +48,7 @@ export function getContext( ), ) - const context = new TranslationContext(overriddenBundles, rootContext.warnMissing) + const context = new TranslationContext(overriddenBundles, rootContext.options) options._fluent = context diff --git a/src/index.ts b/src/index.ts index f9f27aa2..f9f5bdf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import type { FluentBundle, FluentVariable } from '@fluent/bundle' - import { isVue3, shallowRef } from 'vue-demi' +import type { FluentVueOptions } from './types' + import type { InstallFunction, Vue, Vue2, Vue3, Vue3Component } from './types/typesCompat' import type { TranslationWithAttrs } from './TranslationContext' import { TranslationContext } from './TranslationContext' @@ -8,18 +9,10 @@ import { createVue2Directive, createVue3Directive } from './vue/directive' import component from './vue/component' import { getContext } from './getContext' import { RootContextSymbol } from './symbols' -import { warnMissingDefault } from './util/warn' +import { resolveOptions } from './util/options' export { useFluent } from './composition' -export interface FluentVueOptions { - /** Current negotiated fallback chain of languages */ - bundles: Iterable - - /** Custom function for warning about missing translation */ - warnMissing?: ((key: string) => void) | boolean -} - export interface FluentVue { /** Current negotiated fallback chain of languages */ bundles: Iterable @@ -33,15 +26,6 @@ export interface FluentVue { install: InstallFunction } -function getWarnMissing(options: FluentVueOptions) { - if (options.warnMissing === true || options.warnMissing == null) - return warnMissingDefault - else if (options.warnMissing === false) - return () => {} - else - return options.warnMissing -} - /** * Creates FluentVue instance that can be used on a Vue app. * @@ -50,9 +34,9 @@ function getWarnMissing(options: FluentVueOptions) { export function createFluentVue(options: FluentVueOptions): FluentVue { const bundles = shallowRef(options.bundles) - const warnMissing = getWarnMissing(options) + const resolvedOptions = resolveOptions(options) - const rootContext = new TranslationContext(bundles, warnMissing) + const rootContext = new TranslationContext(bundles, resolvedOptions) return { get bundles() { diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 00000000..46747cb9 --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,19 @@ +import type { FluentBundle } from '@fluent/bundle' + +type SimpleNode = Pick + +export interface FluentVueOptions { + /** Current negotiated fallback chain of languages */ + bundles: Iterable + + /** Custom function for warning about missing translation */ + warnMissing?: ((key: string) => void) | boolean + + /** Custom function for parsing markup */ + parseMarkup?: (markup: string) => SimpleNode[] +} + +export interface TranslationContextOptions { + warnMissing: (key: string) => void + parseMarkup: (markup: string) => SimpleNode[] +} diff --git a/src/types/typesCompat.ts b/src/types/typesCompat.ts index 090fc0cc..741d10e9 100644 --- a/src/types/typesCompat.ts +++ b/src/types/typesCompat.ts @@ -1,5 +1,5 @@ import type { - DirectiveOptions as Vue2DirectiveOptions, + ObjectDirective as Vue2DirectiveOptions, } from 'vue-2' import type { Vue as Vue2Type } from 'vue-2/types/vue' import type { DirectiveBinding as Vue2DirectiveBinding } from 'vue-2/types/options' diff --git a/src/util/options.ts b/src/util/options.ts new file mode 100644 index 00000000..75034dc0 --- /dev/null +++ b/src/util/options.ts @@ -0,0 +1,33 @@ +import type { FluentVueOptions, SimpleNode, TranslationContextOptions } from 'src/types' + +import { assert, warn } from './warn' + +function defaultMarkupParser(value: string): SimpleNode[] { + assert(typeof DOMParser !== 'undefined', 'DOMParser is not available. Please provide a custom parseMarkup function.') + + const parser = new DOMParser() + const doc = parser.parseFromString(value, 'text/html') + const nodes = Array.from(doc.body.childNodes) + + return nodes +} + +function defaultWarnMissing(key: string) { + warn(`Could not find translation for key [${key}]`) +} + +function getWarnMissing(options: FluentVueOptions) { + if (options.warnMissing === true || options.warnMissing == null) + return defaultWarnMissing + else if (options.warnMissing === false) + return () => {} + else + return options.warnMissing +} + +export function resolveOptions(options: FluentVueOptions): TranslationContextOptions { + return { + warnMissing: getWarnMissing(options), + parseMarkup: options.parseMarkup ?? defaultMarkupParser, + } +} diff --git a/src/util/warn.ts b/src/util/warn.ts index 530e0627..ff740a86 100644 --- a/src/util/warn.ts +++ b/src/util/warn.ts @@ -7,7 +7,3 @@ export function warn(message: string, ...args: unknown[]): void { if (process.env.NODE_ENV !== 'production') console.warn(`[fluent-vue] ${message}`, ...args) } - -export function warnMissingDefault(key: string) { - warn(`Could not find translation for key [${key}]`) -} diff --git a/src/vue/component.ts b/src/vue/component.ts index 5937c92b..9cdffe48 100644 --- a/src/vue/component.ts +++ b/src/vue/component.ts @@ -1,10 +1,11 @@ import { computed, defineComponent, getCurrentInstance, h, inject } from 'vue-demi' -import type { VueComponent } from '../types/typesCompat' +import type { SimpleNode } from 'src/types' +import type { VueComponent } from 'src/types/typesCompat' import { camelize } from '../util/camelize' import { getContext } from '../getContext' import { RootContextSymbol } from '../symbols' -import { assert } from '../util/warn' +import { assert, warn } from '../util/warn' function getParentWithFluent( instance: VueComponent | null | undefined, @@ -18,12 +19,17 @@ function getParentWithFluent( return parent } +// Match the opening angle bracket (<) in HTML tags, and HTML entities like +// &, &, &. +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) @@ -32,39 +38,71 @@ export default defineComponent({ const parent = getParentWithFluent(instance?.proxy) const fluent = getContext(rootContext, parent) - const fluentParams = computed(() => - Object.assign( + 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 translation = computed(() => { - return fluent.formatWithAttrs(props.path, fluentParams.value) - }) + const result = fluent.formatWithAttrs(props.path, fluentParams) - const camelizedAttrs = computed(() => - Object.fromEntries( - Object.entries(translation.value.attributes).map(([key, value]) => [camelize(key), value]), - ), - ) - - return () => - h( - props.tag, - { - ...attrs, - }, - translation.value.value - .split('\uFFFF') - .map(text => - text.startsWith('\uFFFE') - ? slots[text.replace('\uFFFE', '')]?.(camelizedAttrs.value) - : text, - ), + 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, + ) + } + + // 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) + + // 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) }, }) diff --git a/tsconfig.json b/tsconfig.json index 662d61ec..d22ac973 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "jsx": "preserve", "lib": ["esnext", "dom"], "types": ["node"], - "rootDir": "." + "rootDir": ".", + "skipLibCheck": true }, "include": [ "src"