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: `
+
+
+ {{ generalPagesTerms }}
+
+ `,
+ }
+
+ // 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"