From 1b0d74f795249ff3b9cf382df9b00484c4972192 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 16 Nov 2024 16:50:12 +0200 Subject: [PATCH 01/10] Add an option to parse FTL during build --- __tests__/fixtures/errors.vue | 2 + .../vite/__snapshots__/sfc.spec.ts.snap | Bin 4342 -> 9365 bytes __tests__/frameworks/vite/sfc.spec.ts | 40 ++++++++++++ package.json | 2 + pnpm-lock.yaml | 55 +++++++++++++--- src/plugins/external-plugin.ts | 18 +++--- src/plugins/ftl/inject.ts | 41 ++++++++++++ src/plugins/sfc-plugin.ts | 59 ++++++++---------- src/types.ts | 10 +++ 9 files changed, 173 insertions(+), 54 deletions(-) create mode 100644 src/plugins/ftl/inject.ts diff --git a/__tests__/fixtures/errors.vue b/__tests__/fixtures/errors.vue index dbf44ec3..ec225486 100644 --- a/__tests__/fixtures/errors.vue +++ b/__tests__/fixtures/errors.vue @@ -19,4 +19,6 @@ shared-photos = [female] her stream *[other] their stream }. + +entry-without-error = Hello, World! diff --git a/__tests__/frameworks/vite/__snapshots__/sfc.spec.ts.snap b/__tests__/frameworks/vite/__snapshots__/sfc.spec.ts.snap index 01355e3de0755f59235574ec8235848012c1afd3..8ea879fa833553f799e96aeb025163c20e9c036b 100644 GIT binary patch delta 1040 zcmbV~zityj5XJ=oqqs>53L2#K);52lq`(DIgaoDmx(G#A?0M&MtDJYO-8m=L>9~pp z(V!FJEg~X|2chQ?sA*wt?YlU(rATzMdpq-e-*4_u=j-e558DT0VI920ggs{`puq|R z$HK%6Pub8&U@!sARKygrSU3jO8tcC32dzc*>GPs0f42v-@YdjVeZJWGu)81aKRFQL zGv3DO7)(_DynBE4?oQA=x@^^x%8RYN@^7nE9_{Xw#m>jK84rz|^8RtgmE?U68tt@q zrZ6@s_Pjjr@`*@Npsxc+64PZz#wM~u_X5$XFr_rSpXGUwmxtTG9xtW!rDG9AcTN3&=!Ho&R?f(>5*i5| z>74>CA>+kD*WprLZnTe5Z$U^2lF=pe18G*%zquV_!siv|@%VhgQiK{`;rU(n$uykJ zl^hRpqUn%e_n*>0hUULOR110qTGqB25VZdViH%7{BDqE)R*n%W5ThF*M6lKjcCGn+ fv$&NFtRUocR#)u}ls$s-Xu}^D%=ANT$NOfF#S delta 12 TcmbR0`Au { expect(code).toMatchSnapshot() }) + describe('parseFtl', () => { + it('parses ftl syntax during compilation', async () => { + // Arrange + // Act + const code = await compile({ + plugins: [ + vue3({ + compiler, + }), + SFCFluentPlugin({ + parseFtl: true, + checkSyntax: false, + }), + ], + }, '/fixtures/test.vue') + + // Assert + expect(code).toMatchSnapshot() + }) + + it('generates block code even if it has errors', async () => { + // Arrange + // Act + const code = await compile({ + plugins: [ + vue3({ + compiler, + }), + SFCFluentPlugin({ + parseFtl: true, + checkSyntax: false, + }), + ], + }, '/fixtures/errors.vue') + + // Assert + expect(code).toMatchSnapshot() + }) + }) + it('supports custom blockType', async () => { // Arrange // Act diff --git a/package.json b/package.json index 4e66b37e..4a16bb02 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "release": "dotenv release-it" }, "peerDependencies": { + "@fluent/bundle": "*", "@nuxt/kit": "^3" }, "peerDependenciesMeta": { @@ -111,6 +112,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^4.3.0", + "@fluent/bundle": "^0.18.0", "@nuxt/kit": "^3.15.4", "@nuxt/schema": "^3.15.4", "@release-it-plugins/lerna-changelog": "7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ed9de1..7984497a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,10 @@ importers: devDependencies: '@antfu/eslint-config': specifier: ^4.3.0 - version: 4.3.0(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.13)(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0)) + version: 4.3.0(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.13)(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0)) + '@fluent/bundle': + specifier: ^0.18.0 + version: 0.18.0 '@nuxt/kit': specifier: ^3.15.4 version: 3.15.4(magicast@0.3.5) @@ -44,7 +47,7 @@ importers: version: 5.2.1(vite@6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)) '@vitest/coverage-istanbul': specifier: ^3.0.6 - version: 3.0.7(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0)) + version: 3.0.7(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0)) '@vue/compiler-sfc': specifier: 3.5.13 version: 3.5.13 @@ -80,7 +83,7 @@ importers: version: 6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) vitest: specifier: 3.0.6 - version: 3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) + version: 3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) vue: specifier: 3.5.13 version: 3.5.13(typescript@5.7.3) @@ -434,6 +437,10 @@ packages: resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fluent/bundle@0.18.0': + resolution: {integrity: sha512-8Wfwu9q8F9g2FNnv82g6Ch/E1AW1wwljsUOolH5NEtdJdv0sZTuWvfCM7c3teB9dzNaJA8rn4khpidpozHWYEA==} + engines: {node: '>=14.0.0', npm: '>=7.0.0'} + '@fluent/syntax@0.19.0': resolution: {integrity: sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==} engines: {node: '>=14.0.0', npm: '>=7.0.0'} @@ -1985,6 +1992,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + happy-dom@15.11.6: + resolution: {integrity: sha512-elX7iUTu+5+3b2+NGQc0L3eWyq9jKhuJJ4GpOMxxT/c2pg9O3L5H3ty2VECX0XXZgRmmRqXyOK8brA2hDI6LsQ==} + engines: {node: '>=18.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3770,6 +3781,10 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-sources@3.2.3: resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} engines: {node: '>=10.13.0'} @@ -3787,6 +3802,10 @@ packages: webpack-cli: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -3902,7 +3921,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 - '@antfu/eslint-config@4.3.0(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.13)(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))': + '@antfu/eslint-config@4.3.0(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.13)(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))': dependencies: '@antfu/install-pkg': 1.0.0 '@clack/prompts': 0.10.0 @@ -3911,7 +3930,7 @@ snapshots: '@stylistic/eslint-plugin': 4.0.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/eslint-plugin': 8.25.0(@typescript-eslint/parser@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/parser': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3) - '@vitest/eslint-plugin': 1.1.31(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0)) + '@vitest/eslint-plugin': 1.1.31(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0)) ansis: 3.16.0 cac: 6.7.14 eslint: 9.21.0(jiti@2.4.2) @@ -4220,6 +4239,8 @@ snapshots: '@eslint/core': 0.12.0 levn: 0.4.1 + '@fluent/bundle@0.18.0': {} + '@fluent/syntax@0.19.0': {} '@gar/promisify@1.1.3': {} @@ -4763,7 +4784,7 @@ snapshots: vite: 6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) vue: 3.5.13(typescript@5.7.3) - '@vitest/coverage-istanbul@3.0.7(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))': + '@vitest/coverage-istanbul@3.0.7(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))': dependencies: '@istanbuljs/schema': 0.1.3 debug: 4.4.0 @@ -4775,17 +4796,17 @@ snapshots: magicast: 0.3.5 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) + vitest: 3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.1.31(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))': + '@vitest/eslint-plugin@1.1.31(@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))': dependencies: '@typescript-eslint/utils': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3) eslint: 9.21.0(jiti@2.4.2) optionalDependencies: typescript: 5.7.3 - vitest: 3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) + vitest: 3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0) '@vitest/expect@3.0.6': dependencies: @@ -5970,6 +5991,13 @@ snapshots: graphemer@1.4.0: {} + happy-dom@15.11.6: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + optional: true + has-flag@4.0.0: {} hash-sum@2.0.0: {} @@ -7944,7 +7972,7 @@ snapshots: terser: 5.39.0 yaml: 2.7.0 - vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0): + vitest@3.0.6(@types/debug@4.1.12)(@types/node@22.13.5)(happy-dom@15.11.6)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.6 '@vitest/mocker': 3.0.6(vite@6.1.1(@types/node@22.13.5)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0)) @@ -7969,6 +7997,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.13.5 + happy-dom: 15.11.6 transitivePeerDependencies: - jiti - less @@ -8023,6 +8052,9 @@ snapshots: webidl-conversions@4.0.2: {} + webidl-conversions@7.0.0: + optional: true + webpack-sources@3.2.3: {} webpack-virtual-modules@0.6.2: {} @@ -8057,6 +8089,9 @@ snapshots: - esbuild - uglify-js + whatwg-mimetype@3.0.0: + optional: true + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 diff --git a/src/plugins/external-plugin.ts b/src/plugins/external-plugin.ts index 8e496bb4..6da67f36 100644 --- a/src/plugins/external-plugin.ts +++ b/src/plugins/external-plugin.ts @@ -7,6 +7,7 @@ import MagicString from 'magic-string' import { createUnplugin } from 'unplugin' import { isCustomBlock, parseVueRequest } from '../loader-query' +import { getInjectFtl } from './ftl/inject' import { getSyntaxErrors } from './ftl/parse' const isVue = createFilter(['**/*.vue']) @@ -137,20 +138,15 @@ export const unplugin = createUnplugin((options: ExternalPluginOptions) => { this.error(errorsText) } - const magic = new MagicString(source, { filename: id }) + const injectFtl = getInjectFtl(resolvedOptions) - if (source.length > 0) - magic.update(0, source.length, JSON.stringify(source)) - else - magic.append('""') - magic.prepend(` -import { FluentResource } from '@fluent/bundle' -export default /*#__PURE__*/ new FluentResource(`) - magic.append(')\n') + const result = injectFtl` +export default /*#__PURE__*/ new FluentResource(${source}) +` return { - code: magic.toString(), - map: magic.generateMap(), + code: result.toString(), + map: result.generateMap(), } } diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts new file mode 100644 index 00000000..886ab388 --- /dev/null +++ b/src/plugins/ftl/inject.ts @@ -0,0 +1,41 @@ +import type { SFCPluginOptions } from 'src/types' + +import { FluentResource } from '@fluent/bundle' +import MagicString from 'magic-string' + +type InjectFtlFn = (template: TemplateStringsArray, locale?: string, source?: string) => MagicString + +export function getInjectFtl(options: SFCPluginOptions): InjectFtlFn { + return (template, locale, source) => { + if (source == null) { + source = locale + locale = undefined + } + + if (source == null) + throw new Error('Missing source') + + let magic = new MagicString(source) + const importString = options.parseFtl === true ? '' : '\nimport { FluentResource } from \'@fluent/bundle\'\n' + const localeString = locale == null ? '' : locale + + if (options.parseFtl === true) { + const resource = new FluentResource(source) + magic.overwrite(0, source.length, JSON.stringify(resource)) + } + else { + // Escape string + magic.replace(/"/g, '\\"') + magic.replace(/\n/g, '\\n') + magic = magic.snip(1, -1) + magic.prepend('new FluentResource("') + magic.append('")') + } + + magic.prepend(importString + template[0] + localeString + template[1]) + if (template[2] != null) + magic.append(template[2]) + + return magic + } +} diff --git a/src/plugins/sfc-plugin.ts b/src/plugins/sfc-plugin.ts index 2a5fd84a..05ea8ba3 100644 --- a/src/plugins/sfc-plugin.ts +++ b/src/plugins/sfc-plugin.ts @@ -1,9 +1,10 @@ import type { VitePlugin } from 'unplugin' import type { SFCPluginOptions } from '../types' -import MagicString from 'magic-string' import { createUnplugin } from 'unplugin' + import { isCustomBlock, parseVueRequest } from '../loader-query' +import { getInjectFtl } from './ftl/inject' import { getSyntaxErrors } from './ftl/parse' export const unplugin = createUnplugin((options: SFCPluginOptions, meta) => { @@ -22,48 +23,40 @@ export const unplugin = createUnplugin((options: SFCPluginOptions, meta) => { }, async transform(source: string, id: string) { const { query } = parseVueRequest(id) + if (!isCustomBlock(query, resolvedOptions)) { + return undefined + } - if (isCustomBlock(query, resolvedOptions)) { - const originalSource = source - - const magic = new MagicString(source, { filename: id }) - - source = source.replace(/\r\n/g, '\n').trim() - - if (query.locale == null) - this.error('Custom block does not have locale attribute') - - // I have no idea why webpack processes this file multiple times - if (source.includes('FluentResource') || source.includes('unplugin-fluent-vue-sfc')) - return undefined + const locale = query.locale + if (locale == null) { + this.error('Custom block does not have locale attribute') + return + } - if (resolvedOptions.checkSyntax) { - const errorsText = getSyntaxErrors(source) - if (errorsText) - this.error(errorsText) - } + // I have no idea why webpack processes this file multiple times + if (source.includes('FluentResource') || source.includes('unplugin-fluent-vue-sfc') || source.includes('target.fluent')) + return undefined - if (originalSource.length > 0) - magic.update(0, originalSource.length, JSON.stringify(source)) - else - magic.append('""') + if (resolvedOptions.checkSyntax) { + const errorsText = getSyntaxErrors(source.replace(/\r\n/g, '\n').trim()) + if (errorsText) + this.error(errorsText) + } - magic.prepend(` -import { FluentResource } from '@fluent/bundle' + const injectFtl = getInjectFtl(resolvedOptions) + const result = injectFtl` export default function (Component) { const target = Component.options || Component target.fluent = target.fluent || {} - target.fluent['${query.locale}'] = new FluentResource(`) - magic.append(')\n}\n') + target.fluent['${locale}'] = ${source} +} +` - return { - code: magic.toString(), - map: magic.generateMap(), - } + return { + code: result.toString(), + map: result.generateMap(), } - - return undefined }, } }) diff --git a/src/types.ts b/src/types.ts index 20d5b129..5091b9be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,16 @@ export interface ExternalPluginOptionsFunction extends ExternalPluginOptionsBase export type ExternalPluginOptions = ExternalPluginOptionsFolder | ExternalPluginOptionsFunction export interface SFCPluginOptions { + /** + * Whether to parse the ftl syntax before injecting it into component + */ + parseFtl?: boolean + /** + * Vue custom block name + */ blockType?: string + /** + * Whether to check for syntax errors in the ftl source + */ checkSyntax?: boolean } From 7b9c8bfce921ba2ad263c65b68a2191830a9210d Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 16 Nov 2024 17:08:53 +0200 Subject: [PATCH 02/10] Simplify injecting FTL code --- src/plugins/ftl/inject.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts index 886ab388..7b64b67b 100644 --- a/src/plugins/ftl/inject.ts +++ b/src/plugins/ftl/inject.ts @@ -5,6 +5,10 @@ import MagicString from 'magic-string' type InjectFtlFn = (template: TemplateStringsArray, locale?: string, source?: string) => MagicString +function normalize(str: string) { + return str.replace(/\r\n/g, '\n').trim() +} + export function getInjectFtl(options: SFCPluginOptions): InjectFtlFn { return (template, locale, source) => { if (source == null) { @@ -15,21 +19,16 @@ export function getInjectFtl(options: SFCPluginOptions): InjectFtlFn { if (source == null) throw new Error('Missing source') - let magic = new MagicString(source) + const magic = new MagicString(source) const importString = options.parseFtl === true ? '' : '\nimport { FluentResource } from \'@fluent/bundle\'\n' const localeString = locale == null ? '' : locale if (options.parseFtl === true) { - const resource = new FluentResource(source) + const resource = new FluentResource(normalize(source)) magic.overwrite(0, source.length, JSON.stringify(resource)) } else { - // Escape string - magic.replace(/"/g, '\\"') - magic.replace(/\n/g, '\\n') - magic = magic.snip(1, -1) - magic.prepend('new FluentResource("') - magic.append('")') + magic.overwrite(0, source.length, `new FluentResource(${JSON.stringify(normalize(source))})`) } magic.prepend(importString + template[0] + localeString + template[1]) From 308aac21e2b0cc9fe95bb14565bb7f0b709c3910 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 16 Nov 2024 17:22:31 +0200 Subject: [PATCH 03/10] Handle empty files corectly --- src/plugins/ftl/inject.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts index 7b64b67b..52ee5ad1 100644 --- a/src/plugins/ftl/inject.ts +++ b/src/plugins/ftl/inject.ts @@ -23,7 +23,10 @@ export function getInjectFtl(options: SFCPluginOptions): InjectFtlFn { const importString = options.parseFtl === true ? '' : '\nimport { FluentResource } from \'@fluent/bundle\'\n' const localeString = locale == null ? '' : locale - if (options.parseFtl === true) { + if (source.length === 0) { + magic.append('undefined') + } + else if (options.parseFtl === true) { const resource = new FluentResource(normalize(source)) magic.overwrite(0, source.length, JSON.stringify(resource)) } From f286b7edf3477aa0a537daa469fd0f8ac546abca Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 16 Nov 2024 17:49:44 +0200 Subject: [PATCH 04/10] Parse FTL in ExternalPlugin --- .../vite/__snapshots__/external.spec.ts.snap | Bin 9290 -> 12662 bytes __tests__/frameworks/vite/external.spec.ts | 23 ++++++++++++++++++ src/plugins/external-plugin.ts | 16 +++++------- src/plugins/ftl/inject.ts | 4 +-- src/types.ts | 7 +++++- 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/__tests__/frameworks/vite/__snapshots__/external.spec.ts.snap b/__tests__/frameworks/vite/__snapshots__/external.spec.ts.snap index 7c33fa79696b15765f7c2af0fc0f1c96649d8f50..aa964a8ea449defb2afcdec0ceee395e82234366 100644 GIT binary patch delta 898 zcmbW0u};G<5QfEokWw}V7|ksRDFwk;2^OT%i3b36aO*3vNbM?ifGUc@3&2tq-U8GI z=)%M+An_E470zi?QYfv&W}p4vfA`t<()egSo_fo$?WNw3d-bK&SPAS%g@Pi3QRQv7 z$0LJv8?r-|FAeL^#`Klt*G$BbROhU%yFD3lE43QDZ}?Al4=aPU%58>ftH};n0xd22 zP&-RN3aCMVVW_LI!3cb~FZk z*rI`2lBOiflbe7?D5||c+f191!Q!-KZ-XJWEy>3V9g)e*x}1?d(y_9xGAdbeWvL`; zHoHLTdLlEn)Guh2w^FnZTnH45f-Fx+xe}Nww*leH9SeTr3}t*-ncsAsOyO1G)JzGc zv2?e#NBOM&7Y6q{$4H;2D9T~T3J&x7=<;xiLm&+!P{_Lr5p?jE7^AY7&okCP00Qmh qw|v56pP1~-_;1bBO$hfi{q*~(u^EmkuYb9;OrDY^SMBC_E;q)>0s0~=Al~GS-1j!$l>EzyN19u05nMM={Sg3R CG8*Cl diff --git a/__tests__/frameworks/vite/external.spec.ts b/__tests__/frameworks/vite/external.spec.ts index bb3d478c..e4b6dc36 100644 --- a/__tests__/frameworks/vite/external.spec.ts +++ b/__tests__/frameworks/vite/external.spec.ts @@ -74,6 +74,29 @@ describe('Vite external', () => { expect(code).toMatchSnapshot() }) + describe('parseFtl', () => { + it('parses ftl syntax during compilation', async () => { + // Arrange + // Act + const code = await compile({ + plugins: [ + vue3({ + compiler, + }), + ExternalFluentPlugin({ + baseDir: resolve(baseDir, 'fixtures'), + ftlDir: resolve(baseDir, 'fixtures/ftl'), + locales: ['en', 'da'], + parseFtl: true, + }), + ], + }, '/fixtures/components/external.vue') + + // Assert + expect(code).toMatchSnapshot() + }) + }) + it('virtual:ftl-for-file', async () => { // Arrange // Act diff --git a/src/plugins/external-plugin.ts b/src/plugins/external-plugin.ts index 6da67f36..d47a33a1 100644 --- a/src/plugins/external-plugin.ts +++ b/src/plugins/external-plugin.ts @@ -132,7 +132,7 @@ export const unplugin = createUnplugin((options: ExternalPluginOptions) => { } if (isFtl(id)) { - if (options.checkSyntax) { + if (resolvedOptions.checkSyntax) { const errorsText = getSyntaxErrors(source) if (errorsText) this.error(errorsText) @@ -158,19 +158,15 @@ export default /*#__PURE__*/ new FluentResource(${source}) this.error(errorsText) } - const magic = new MagicString(source, { filename: id }) - if (source.length > 0) - magic.update(0, source.length, JSON.stringify(source)) - else - magic.append('""') - magic.prepend(` -import { FluentResource } from '@fluent/bundle' + const injectFtl = getInjectFtl(resolvedOptions) + const magic = injectFtl` export default function (Component) { const target = Component.options || Component target.fluent = target.fluent || {} - target.fluent['${query.locale}'] = new FluentResource(`) - magic.append(')\n}') + target.fluent['${query.locale}'] = ${source} +} +` return { code: magic.toString(), diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts index 52ee5ad1..c99de67a 100644 --- a/src/plugins/ftl/inject.ts +++ b/src/plugins/ftl/inject.ts @@ -1,5 +1,3 @@ -import type { SFCPluginOptions } from 'src/types' - import { FluentResource } from '@fluent/bundle' import MagicString from 'magic-string' @@ -9,7 +7,7 @@ function normalize(str: string) { return str.replace(/\r\n/g, '\n').trim() } -export function getInjectFtl(options: SFCPluginOptions): InjectFtlFn { +export function getInjectFtl(options: { parseFtl?: boolean }): InjectFtlFn { return (template, locale, source) => { if (source == null) { source = locale diff --git a/src/types.ts b/src/types.ts index 5091b9be..c9da0442 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,12 @@ export interface ExternalPluginOptionsFunction extends ExternalPluginOptionsBase getFtlPath: (locale: string, vuePath: string) => string } -export type ExternalPluginOptions = ExternalPluginOptionsFolder | ExternalPluginOptionsFunction +export type ExternalPluginOptions = (ExternalPluginOptionsFolder | ExternalPluginOptionsFunction) & { + /** + * Whether to parse the ftl syntax before injecting it into component + */ + parseFtl?: boolean +} export interface SFCPluginOptions { /** From 95c40c70137097982d0d82bdae68d0200e2b4878 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 16 Nov 2024 17:59:55 +0200 Subject: [PATCH 05/10] Reuse code for finding syntax errors --- src/loader-query.ts | 4 ++-- src/plugins/external-plugin.ts | 34 ++++++++++------------------------ src/plugins/ftl/inject.ts | 24 ++++++++++++++++++++---- src/plugins/sfc-plugin.ts | 17 +++++------------ 4 files changed, 37 insertions(+), 42 deletions(-) diff --git a/src/loader-query.ts b/src/loader-query.ts index 91d08251..20cde329 100644 --- a/src/loader-query.ts +++ b/src/loader-query.ts @@ -17,13 +17,13 @@ export function parseVueRequest(id: string) { ret.type = params.get('type') as VueQuery['type'] if (params.has('blockType')) - ret.blockType = params.get('blockType') + ret.blockType = params.get('blockType') ?? undefined if (params.has('index')) ret.index = Number(params.get('index')) if (params.has('locale')) - ret.locale = params.get('locale') + ret.locale = params.get('locale') ?? undefined return { filename, diff --git a/src/plugins/external-plugin.ts b/src/plugins/external-plugin.ts index d47a33a1..b9d7a879 100644 --- a/src/plugins/external-plugin.ts +++ b/src/plugins/external-plugin.ts @@ -8,7 +8,6 @@ import MagicString from 'magic-string' import { createUnplugin } from 'unplugin' import { isCustomBlock, parseVueRequest } from '../loader-query' import { getInjectFtl } from './ftl/inject' -import { getSyntaxErrors } from './ftl/parse' const isVue = createFilter(['**/*.vue']) const isFtl = createFilter(['**/*.ftl']) @@ -42,6 +41,7 @@ function isFluentCustomBlock(id: string) { export const unplugin = createUnplugin((options: ExternalPluginOptions) => { const resolvedOptions = { checkSyntax: true, + parseFtl: false, virtualModuleName: 'virtual:ftl-for-file', getFtlPath: undefined as ((locale: string, vuePath: string) => string) | undefined, ...options, @@ -132,35 +132,21 @@ export const unplugin = createUnplugin((options: ExternalPluginOptions) => { } if (isFtl(id)) { - if (resolvedOptions.checkSyntax) { - const errorsText = getSyntaxErrors(source) - if (errorsText) - this.error(errorsText) - } - const injectFtl = getInjectFtl(resolvedOptions) - const result = injectFtl` export default /*#__PURE__*/ new FluentResource(${source}) ` - return { - code: result.toString(), - map: result.generateMap(), - } + if (result.error) + this.error(result.error) + + return result.code } const query = parseVueRequest(id).query if (isFluentCustomBlock(id)) { - if (options.checkSyntax) { - const errorsText = getSyntaxErrors(source) - if (errorsText) - this.error(errorsText) - } - const injectFtl = getInjectFtl(resolvedOptions) - - const magic = injectFtl` + const result = injectFtl` export default function (Component) { const target = Component.options || Component target.fluent = target.fluent || {} @@ -168,10 +154,10 @@ export default function (Component) { } ` - return { - code: magic.toString(), - map: magic.generateMap(), - } + if (result.error) + this.error(result.error) + + return result.code } return undefined diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts index c99de67a..8f089be6 100644 --- a/src/plugins/ftl/inject.ts +++ b/src/plugins/ftl/inject.ts @@ -1,13 +1,14 @@ import { FluentResource } from '@fluent/bundle' -import MagicString from 'magic-string' +import MagicString, { type SourceMap } from 'magic-string' +import { getSyntaxErrors } from './parse' -type InjectFtlFn = (template: TemplateStringsArray, locale?: string, source?: string) => MagicString +type InjectFtlFn = (template: TemplateStringsArray, locale?: string, source?: string) => { code?: { code: string, map: SourceMap }, error?: string } function normalize(str: string) { return str.replace(/\r\n/g, '\n').trim() } -export function getInjectFtl(options: { parseFtl?: boolean }): InjectFtlFn { +export function getInjectFtl(options: { checkSyntax: boolean, parseFtl: boolean }): InjectFtlFn { return (template, locale, source) => { if (source == null) { source = locale @@ -17,6 +18,16 @@ export function getInjectFtl(options: { parseFtl?: boolean }): InjectFtlFn { if (source == null) throw new Error('Missing source') + if (options.checkSyntax) { + const errorsText = getSyntaxErrors(source.replace(/\r\n/g, '\n').trim()) + + if (errorsText) { + return { + error: errorsText, + } + } + } + const magic = new MagicString(source) const importString = options.parseFtl === true ? '' : '\nimport { FluentResource } from \'@fluent/bundle\'\n' const localeString = locale == null ? '' : locale @@ -36,6 +47,11 @@ export function getInjectFtl(options: { parseFtl?: boolean }): InjectFtlFn { if (template[2] != null) magic.append(template[2]) - return magic + return { + code: { + code: magic.toString(), + map: magic.generateMap(), + }, + } } } diff --git a/src/plugins/sfc-plugin.ts b/src/plugins/sfc-plugin.ts index 05ea8ba3..2a7c9396 100644 --- a/src/plugins/sfc-plugin.ts +++ b/src/plugins/sfc-plugin.ts @@ -5,12 +5,12 @@ import { createUnplugin } from 'unplugin' import { isCustomBlock, parseVueRequest } from '../loader-query' import { getInjectFtl } from './ftl/inject' -import { getSyntaxErrors } from './ftl/parse' export const unplugin = createUnplugin((options: SFCPluginOptions, meta) => { const resolvedOptions = { blockType: 'fluent', checkSyntax: true, + parseFtl: false, ...options, } @@ -37,14 +37,7 @@ export const unplugin = createUnplugin((options: SFCPluginOptions, meta) => { if (source.includes('FluentResource') || source.includes('unplugin-fluent-vue-sfc') || source.includes('target.fluent')) return undefined - if (resolvedOptions.checkSyntax) { - const errorsText = getSyntaxErrors(source.replace(/\r\n/g, '\n').trim()) - if (errorsText) - this.error(errorsText) - } - const injectFtl = getInjectFtl(resolvedOptions) - const result = injectFtl` export default function (Component) { const target = Component.options || Component @@ -53,10 +46,10 @@ export default function (Component) { } ` - return { - code: result.toString(), - map: result.generateMap(), - } + if (result.error) + this.error(result.error) + + return result.code }, } }) From 0b6dd3ab5a23fc19b26fc9fbfb64651157f0fa90 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 16 Nov 2024 18:30:51 +0200 Subject: [PATCH 06/10] Correctly handle importing external FTL files --- __tests__/frameworks/vite/external.spec.ts | 53 ++++++++++++++++++++++ src/plugins/external-plugin.ts | 2 +- src/plugins/ftl/inject.ts | 12 +++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/__tests__/frameworks/vite/external.spec.ts b/__tests__/frameworks/vite/external.spec.ts index e4b6dc36..5661f780 100644 --- a/__tests__/frameworks/vite/external.spec.ts +++ b/__tests__/frameworks/vite/external.spec.ts @@ -129,4 +129,57 @@ describe('Vite external', () => { " `) }) + + it('can import FTL files', async () => { + // Arrange + // Act + const code = await compile({ + plugins: [ + vue3({ + compiler, + }), + ExternalFluentPlugin({ + baseDir: resolve(baseDir, 'fixtures'), + ftlDir: resolve(baseDir, 'fixtures/ftl'), + locales: ['en', 'da'], + }), + ], + }, '/fixtures/ftl/en/importer.js.ftl') + + // Assert + expect(code).toMatchInlineSnapshot(` + "=== /fixtures/ftl/en/importer.js.ftl === + + import { FluentResource } from "/@id/virtual:empty:fluent-bundle" + + export default /*#__PURE__*/ new FluentResource("key = Translations for js file") + " + `) + }) + + it('can parse FTL files', async () => { + // Arrange + // Act + const code = await compile({ + plugins: [ + vue3({ + compiler, + }), + ExternalFluentPlugin({ + baseDir: resolve(baseDir, 'fixtures'), + ftlDir: resolve(baseDir, 'fixtures/ftl'), + locales: ['en', 'da'], + parseFtl: true, + }), + ], + }, '/fixtures/ftl/en/importer.js.ftl') + + // Assert + expect(code).toMatchInlineSnapshot(` + "=== /fixtures/ftl/en/importer.js.ftl === + + export default /*#__PURE__*/ {"body":[{"id":"key","value":"Translations for js file","attributes":{}}]} + " + `) + }) }) diff --git a/src/plugins/external-plugin.ts b/src/plugins/external-plugin.ts index b9d7a879..7e099dfa 100644 --- a/src/plugins/external-plugin.ts +++ b/src/plugins/external-plugin.ts @@ -134,7 +134,7 @@ export const unplugin = createUnplugin((options: ExternalPluginOptions) => { if (isFtl(id)) { const injectFtl = getInjectFtl(resolvedOptions) const result = injectFtl` -export default /*#__PURE__*/ new FluentResource(${source}) +export default /*#__PURE__*/ ${source} ` if (result.error) diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts index 8f089be6..2f1419bf 100644 --- a/src/plugins/ftl/inject.ts +++ b/src/plugins/ftl/inject.ts @@ -19,7 +19,7 @@ export function getInjectFtl(options: { checkSyntax: boolean, parseFtl: boolean throw new Error('Missing source') if (options.checkSyntax) { - const errorsText = getSyntaxErrors(source.replace(/\r\n/g, '\n').trim()) + const errorsText = getSyntaxErrors(normalize(source)) if (errorsText) { return { @@ -30,7 +30,6 @@ export function getInjectFtl(options: { checkSyntax: boolean, parseFtl: boolean const magic = new MagicString(source) const importString = options.parseFtl === true ? '' : '\nimport { FluentResource } from \'@fluent/bundle\'\n' - const localeString = locale == null ? '' : locale if (source.length === 0) { magic.append('undefined') @@ -43,9 +42,14 @@ export function getInjectFtl(options: { checkSyntax: boolean, parseFtl: boolean magic.overwrite(0, source.length, `new FluentResource(${JSON.stringify(normalize(source))})`) } - magic.prepend(importString + template[0] + localeString + template[1]) - if (template[2] != null) + if (template.length === 2) { + magic.prepend(importString + template[0]) + magic.append(template[1]) + } + else if (template.length === 3) { + magic.prepend(importString + template[0] + locale + template[1]) magic.append(template[2]) + } return { code: { From 5b8da263a2d4f7e3b92e4f8d3948a1cffb580bd0 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Sat, 16 Nov 2024 18:48:06 +0200 Subject: [PATCH 07/10] Use empty resouce instead of undefined --- .../vite/__snapshots__/external.spec.ts.snap | Bin 12662 -> 12668 bytes src/plugins/ftl/inject.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/frameworks/vite/__snapshots__/external.spec.ts.snap b/__tests__/frameworks/vite/__snapshots__/external.spec.ts.snap index aa964a8ea449defb2afcdec0ceee395e82234366..15fab514af7631b82d31276ca9c74241b5669a7c 100644 GIT binary patch delta 78 zcmeyC^e1V Date: Mon, 24 Feb 2025 19:33:53 +0200 Subject: [PATCH 08/10] Correctly add pure annotation --- __tests__/frameworks/vite/external.spec.ts | 2 +- src/plugins/external-plugin.ts | 4 ++-- src/plugins/ftl/inject.ts | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/__tests__/frameworks/vite/external.spec.ts b/__tests__/frameworks/vite/external.spec.ts index 5661f780..968fd99d 100644 --- a/__tests__/frameworks/vite/external.spec.ts +++ b/__tests__/frameworks/vite/external.spec.ts @@ -178,7 +178,7 @@ describe('Vite external', () => { expect(code).toMatchInlineSnapshot(` "=== /fixtures/ftl/en/importer.js.ftl === - export default /*#__PURE__*/ {"body":[{"id":"key","value":"Translations for js file","attributes":{}}]} + export default {"body":[{"id":"key","value":"Translations for js file","attributes":{}}]} " `) }) diff --git a/src/plugins/external-plugin.ts b/src/plugins/external-plugin.ts index 7e099dfa..89ef683c 100644 --- a/src/plugins/external-plugin.ts +++ b/src/plugins/external-plugin.ts @@ -132,9 +132,9 @@ export const unplugin = createUnplugin((options: ExternalPluginOptions) => { } if (isFtl(id)) { - const injectFtl = getInjectFtl(resolvedOptions) + const injectFtl = getInjectFtl(resolvedOptions, true) const result = injectFtl` -export default /*#__PURE__*/ ${source} +export default ${source} ` if (result.error) diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts index ddcd183e..7d22f98e 100644 --- a/src/plugins/ftl/inject.ts +++ b/src/plugins/ftl/inject.ts @@ -8,13 +8,15 @@ function normalize(str: string) { return str.replace(/\r\n/g, '\n').trim() } -export function getInjectFtl(options: { checkSyntax: boolean, parseFtl: boolean }): InjectFtlFn { +export function getInjectFtl(options: { checkSyntax: boolean, parseFtl: boolean }, addPureAnotation = false): InjectFtlFn { return (template, locale, source) => { if (source == null) { source = locale locale = undefined } + const pureAnotation = addPureAnotation ? '/*#__PURE__*/ ' : '' + if (source == null) throw new Error('Missing source') @@ -39,7 +41,7 @@ export function getInjectFtl(options: { checkSyntax: boolean, parseFtl: boolean magic.overwrite(0, source.length, JSON.stringify(resource)) } else { - magic.overwrite(0, source.length, `new FluentResource(${JSON.stringify(normalize(source))})`) + magic.overwrite(0, source.length, `${pureAnotation}new FluentResource(${JSON.stringify(normalize(source))})`) } if (template.length === 2) { From 85f96ce752ebd3ccd79c510f314d7aff3d54a914 Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Mon, 24 Feb 2025 20:04:47 +0200 Subject: [PATCH 09/10] Fix eslint warnings --- src/plugins/ftl/inject.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/ftl/inject.ts b/src/plugins/ftl/inject.ts index 7d22f98e..e4907ced 100644 --- a/src/plugins/ftl/inject.ts +++ b/src/plugins/ftl/inject.ts @@ -1,5 +1,6 @@ +import type { SourceMap } from 'magic-string' import { FluentResource } from '@fluent/bundle' -import MagicString, { type SourceMap } from 'magic-string' +import MagicString from 'magic-string' import { getSyntaxErrors } from './parse' type InjectFtlFn = (template: TemplateStringsArray, locale?: string, source?: string) => { code?: { code: string, map: SourceMap }, error?: string } From 1795553e3cdb899b8ac4618fc15826c92cacad9e Mon Sep 17 00:00:00 2001 From: Ivan Demchuk Date: Mon, 24 Feb 2025 20:51:02 +0200 Subject: [PATCH 10/10] Update example in readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4ad8f7e3..dbb4d2c3 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,14 @@ export default defineConfig({ SFCFluentPlugin({ // define messages in SFCs blockType: 'fluent', // default 'fluent' - name of the block in SFCs checkSyntax: true, // default true - whether to check syntax of the messages + parseFtl: false, // default false - whether to parse ftl files during build }), ExternalFluentPlugin({ // define messages in external ftl files baseDir: path.resolve('src'), // required - base directory for Vue files ftlDir: path.resolve('src/locales'), // required - directory with ftl files locales: ['en', 'da'], // required - list of locales checkSyntax: true, // default true - whether to check syntax of the messages + parseFtl: false, // default false - whether to parse ftl files during build }), ], })