diff --git a/.changeset/happy-clocks-hug.md b/.changeset/happy-clocks-hug.md new file mode 100644 index 0000000..3f6cf52 --- /dev/null +++ b/.changeset/happy-clocks-hug.md @@ -0,0 +1,19 @@ +--- +'@vintl/vintl': minor +--- + +Add more formatting components similar to `react-intl` + +- `FormattedDate`, `FormattedDateParts` +- `FormattedTime`, `FormattedTimeParts` +- `FormattedDateTimeRange` +- `FormattedRelativeTime` (static, unlike `react-intl`) +- `FormattedNumber`, `FormattedNumberParts` +- `FormattedPlural` +- `FormattedList`, `FormattedListParts` +- `FormattedDisplayName` +- `FormattedMessage` + +Since this is a Vue library, they use slots to pass formatted values (otherwise rendering them as is). + +`FormattedMessage` is very similar to `IntlFormatted`, but accepts descriptor properties and does not allow to format raw messages. diff --git a/.changeset/new-turkeys-pump.md b/.changeset/new-turkeys-pump.md new file mode 100644 index 0000000..852b494 --- /dev/null +++ b/.changeset/new-turkeys-pump.md @@ -0,0 +1,27 @@ +--- +'@vintl/vintl': major +--- + +Remove deprecated composables + +Composables, such as `useI18n`, `useTranslate` and `useFormatters` were previously deprecated with the warning that they will be removed in the next major version. They now get removed as scheduled. + +Migration steps: + +- Use `useVIntl` everywhere you used `useI18n`, the latter was just an alias for `useVIntl` in previous versions. + +- To retrieve translate function previously returned by `useTranslate`, destructure `formatMessage` function from the controller: + + ```js + const { formatMessage } = useVIntl + ``` + + It is bound to the controller and as such is safe to use on its own. + +- To retrieve formatters previously returned by `useFormatters`, destructure `formats` property from the controller: + + ```js + const { formats } = useVIntl + ``` + + It is reactively updated object and also safe to use on its own. diff --git a/.changeset/olive-bugs-visit.md b/.changeset/olive-bugs-visit.md new file mode 100644 index 0000000..b6f2ff7 --- /dev/null +++ b/.changeset/olive-bugs-visit.md @@ -0,0 +1,7 @@ +--- +'@vintl/vintl': major +--- + +Bump Vue version to 3.3.4 + +We're now requiring a newer Vue version because we are relying on functionality added in Vue 3.3, such as generic components. Since it's not compatible with previous versions of Vue, this is marked as a breaking change. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..1b116ae --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,8 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "@vintl/vintl": "4.2.0" + }, + "changesets": [] +} diff --git a/.changeset/tender-zoos-shop.md b/.changeset/tender-zoos-shop.md new file mode 100644 index 0000000..9ddd35b --- /dev/null +++ b/.changeset/tender-zoos-shop.md @@ -0,0 +1,7 @@ +--- +'@vintl/vintl': minor +--- + +Add `useMessages` composable + +v5 introduces a new API that allows you to create messages more effectively. diff --git a/.eslintrc.json b/.eslintrc.json index d852a24..a19df88 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -30,6 +30,7 @@ "indent": "off", "no-useless-constructor": "off", "@typescript-eslint/no-useless-constructor": "warn", + "vue/one-component-per-file": "off", "@typescript-eslint/no-unsafe-declaration-merging": "off" }, "parserOptions": { @@ -41,7 +42,7 @@ "parserOptions": { "project": "./tsconfig.build.json" } }, { - "files": ["./vitest.config.ts", "./test/*.test.ts"], + "files": ["./vitest.config.ts", "./test/**/*.ts", "./test/**/*.tsx"], "parserOptions": { "project": "./tsconfig.tests.json" } } ] diff --git a/build.config.ts b/build.config.ts index bf9671f..63b213b 100644 --- a/build.config.ts +++ b/build.config.ts @@ -57,7 +57,7 @@ export default defineBuildConfig({ outDir: './dist', }, { - input: './src/components', + input: './src/components/index', name: 'components', builder: 'rollup', declaration: true, @@ -79,4 +79,10 @@ export default defineBuildConfig({ }, ], declaration: true, + rollup: { + esbuild: { + jsx: 'automatic', + jsxImportSource: 'vue', + }, + }, }) diff --git a/package.json b/package.json index 99a2160..7ac2169 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "devDependencies": { "@changesets/cli": "^2.26.2", "@nuxtjs/eslint-config-typescript": "^12.1.0", + "@testing-library/vue": "^7.0.0", "@types/node": "^18.18.7", "@vue/runtime-core": "^3.3.7", "del-cli": "^5.1.0", @@ -86,17 +87,19 @@ "typescript": "^5.2.2", "unbuild": "^2.0.0", "vitepress": "1.0.0-rc.24", - "vitest": "^0.34.6" + "vitest": "^0.34.6", + "vue": "^3.3.7" }, "dependencies": { "@braw/async-computed": "^5.0.2", + "@formatjs/ecma402-abstract": "^1.17.2", "@formatjs/icu-messageformat-parser": "^2.7.0", "@formatjs/intl": "^2.9.5", "@formatjs/intl-localematcher": "^0.4.2", "intl-messageformat": "^10.5.4" }, "peerDependencies": { - "vue": "^3.2.47" + "vue": "^3.3.7" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91cd29e..eeabcec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,10 @@ settings: dependencies: '@braw/async-computed': specifier: ^5.0.2 - version: 5.0.2(vue@3.2.47) + version: 5.0.2(vue@3.3.7) + '@formatjs/ecma402-abstract': + specifier: ^1.17.2 + version: 1.17.2 '@formatjs/icu-messageformat-parser': specifier: ^2.7.0 version: 2.7.0 @@ -20,9 +23,6 @@ dependencies: intl-messageformat: specifier: ^10.5.4 version: 10.5.4 - vue: - specifier: ^3.2.47 - version: 3.2.47 devDependencies: '@changesets/cli': @@ -31,6 +31,9 @@ devDependencies: '@nuxtjs/eslint-config-typescript': specifier: ^12.1.0 version: 12.1.0(eslint@8.52.0)(typescript@5.2.2) + '@testing-library/vue': + specifier: ^7.0.0 + version: 7.0.0(@vue/compiler-sfc@3.3.7)(vue@3.3.7) '@types/node': specifier: ^18.18.7 version: 18.18.7 @@ -64,6 +67,9 @@ devDependencies: vitest: specifier: ^0.34.6 version: 0.34.6(happy-dom@12.10.3) + vue: + specifier: ^3.3.7 + version: 3.3.7(typescript@5.2.2) packages: @@ -360,19 +366,13 @@ packages: js-tokens: 4.0.0 dev: true - /@babel/parser@7.20.7: - resolution: {integrity: sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg==} - engines: {node: '>=6.0.0'} - dependencies: - '@babel/types': 7.22.11 - dev: false - /@babel/parser@7.22.11: resolution: {integrity: sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==} engines: {node: '>=6.0.0'} hasBin: true dependencies: '@babel/types': 7.22.11 + dev: true /@babel/parser@7.23.0: resolution: {integrity: sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==} @@ -380,7 +380,6 @@ packages: hasBin: true dependencies: '@babel/types': 7.22.11 - dev: true /@babel/runtime@7.22.3: resolution: {integrity: sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==} @@ -429,12 +428,12 @@ packages: '@babel/helper-validator-identifier': 7.22.5 to-fast-properties: 2.0.0 - /@braw/async-computed@5.0.2(vue@3.2.47): + /@braw/async-computed@5.0.2(vue@3.3.7): resolution: {integrity: sha512-fThqjZBTPvWtbD90Nkd4IldN7dpCkxfvthuk12ZBjkPPjh+wuRGi3HYiUqUSAOOVS0NHSxpsQFfg+qO275FtYA==} peerDependencies: vue: ^2.7 || ^3.2.45 dependencies: - vue: 3.2.47 + vue: 3.3.7(typescript@5.2.2) dev: false /@changesets/apply-release-plan@6.1.4: @@ -1216,7 +1215,6 @@ packages: /@jridgewell/sourcemap-codec@1.4.15: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - dev: true /@jridgewell/trace-mapping@0.3.18: resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} @@ -1306,6 +1304,10 @@ packages: - supports-color dev: true + /@one-ini/wasm@0.1.1: + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + dev: true + /@rollup/plugin-alias@5.0.0(rollup@3.28.1): resolution: {integrity: sha512-l9hY5chSCjuFRPsnRm16twWBiSApl2uYFLsepQYwtBuAxNMQ/1dJqADld40P0Jkqm65GRTLy/AC6hnpVebtLsA==} engines: {node: '>=14.0.0'} @@ -1401,6 +1403,40 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@testing-library/dom@9.3.3: + resolution: {integrity: sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==} + engines: {node: '>=14'} + dependencies: + '@babel/code-frame': 7.22.10 + '@babel/runtime': 7.22.3 + '@types/aria-query': 5.0.3 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/vue@7.0.0(@vue/compiler-sfc@3.3.7)(vue@3.3.7): + resolution: {integrity: sha512-JU/q93HGo2qdm1dCgWymkeQlfpC0/0/DBZ2nAHgEAsVZxX11xVIxT7gbXdI7HACQpUbsUWt1zABGU075Fzt9XQ==} + engines: {node: '>=14'} + peerDependencies: + '@vue/compiler-sfc': '>= 3' + vue: '>= 3' + dependencies: + '@babel/runtime': 7.22.3 + '@testing-library/dom': 9.3.3 + '@vue/compiler-sfc': 3.3.7 + '@vue/test-utils': 2.4.1(vue@3.3.7) + vue: 3.3.7(typescript@5.2.2) + transitivePeerDependencies: + - '@vue/server-renderer' + dev: true + + /@types/aria-query@5.0.3: + resolution: {integrity: sha512-0Z6Tr7wjKJIk4OUEjVUQMtyunLDy339vcMaj38Kpj6jM2OE1p3S4kXExKZ7a3uXQAPCoy3sbrP1wibDKaf39oA==} + dev: true + /@types/chai-subset@1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} dependencies: @@ -1678,15 +1714,6 @@ packages: pretty-format: 29.7.0 dev: true - /@vue/compiler-core@3.2.47: - resolution: {integrity: sha512-p4D7FDnQb7+YJmO2iPEv0SQNeNzcbHdGByJDsT4lynf63AFkOTFN07HsiRSvjGo0QrxR/o3d0hUyNCUnBU2Tig==} - dependencies: - '@babel/parser': 7.22.11 - '@vue/shared': 3.2.47 - estree-walker: 2.0.2 - source-map: 0.6.1 - dev: false - /@vue/compiler-core@3.3.7: resolution: {integrity: sha512-pACdY6YnTNVLXsB86YD8OF9ihwpolzhhtdLVHhBL6do/ykr6kKXNYABRtNMGrsQXpEXXyAdwvWWkuTbs4MFtPQ==} dependencies: @@ -1694,36 +1721,12 @@ packages: '@vue/shared': 3.3.7 estree-walker: 2.0.2 source-map-js: 1.0.2 - dev: true - - /@vue/compiler-dom@3.2.47: - resolution: {integrity: sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==} - dependencies: - '@vue/compiler-core': 3.2.47 - '@vue/shared': 3.2.47 - dev: false /@vue/compiler-dom@3.3.7: resolution: {integrity: sha512-0LwkyJjnUPssXv/d1vNJ0PKfBlDoQs7n81CbO6Q0zdL7H1EzqYRrTVXDqdBVqro0aJjo/FOa1qBAPVI4PGSHBw==} dependencies: '@vue/compiler-core': 3.3.7 '@vue/shared': 3.3.7 - dev: true - - /@vue/compiler-sfc@3.2.47: - resolution: {integrity: sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==} - dependencies: - '@babel/parser': 7.20.7 - '@vue/compiler-core': 3.2.47 - '@vue/compiler-dom': 3.2.47 - '@vue/compiler-ssr': 3.2.47 - '@vue/reactivity-transform': 3.2.47 - '@vue/shared': 3.2.47 - estree-walker: 2.0.2 - magic-string: 0.25.9 - postcss: 8.4.23 - source-map: 0.6.1 - dev: false /@vue/compiler-sfc@3.3.7: resolution: {integrity: sha512-7pfldWy/J75U/ZyYIXRVqvLRw3vmfxDo2YLMwVtWVNew8Sm8d6wodM+OYFq4ll/UxfqVr0XKiVwti32PCrruAw==} @@ -1738,36 +1741,17 @@ packages: magic-string: 0.30.5 postcss: 8.4.31 source-map-js: 1.0.2 - dev: true - - /@vue/compiler-ssr@3.2.47: - resolution: {integrity: sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==} - dependencies: - '@vue/compiler-dom': 3.2.47 - '@vue/shared': 3.2.47 - dev: false /@vue/compiler-ssr@3.3.7: resolution: {integrity: sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==} dependencies: '@vue/compiler-dom': 3.3.7 '@vue/shared': 3.3.7 - dev: true /@vue/devtools-api@6.5.1: resolution: {integrity: sha512-+KpckaAQyfbvshdDW5xQylLni1asvNSGme1JFs8I1+/H5pHEhqUKMEQD/qn3Nx5+/nycBq11qAEi8lk+LXI2dA==} dev: true - /@vue/reactivity-transform@3.2.47: - resolution: {integrity: sha512-m8lGXw8rdnPVVIdIFhf0LeQ/ixyHkH5plYuS83yop5n7ggVJU+z5v0zecwEnX7fa7HNLBhh2qngJJkxpwEEmYA==} - dependencies: - '@babel/parser': 7.22.11 - '@vue/compiler-core': 3.2.47 - '@vue/shared': 3.2.47 - estree-walker: 2.0.2 - magic-string: 0.25.9 - dev: false - /@vue/reactivity-transform@3.3.7: resolution: {integrity: sha512-APhRmLVbgE1VPGtoLQoWBJEaQk4V8JUsqrQihImVqKT+8U6Qi3t5ATcg4Y9wGAPb3kIhetpufyZ1RhwbZCIdDA==} dependencies: @@ -1776,41 +1760,17 @@ packages: '@vue/shared': 3.3.7 estree-walker: 2.0.2 magic-string: 0.30.5 - dev: true - - /@vue/reactivity@3.2.47: - resolution: {integrity: sha512-7khqQ/75oyyg+N/e+iwV6lpy1f5wq759NdlS1fpAhFXa8VeAIKGgk2E/C4VF59lx5b+Ezs5fpp/5WsRYXQiKxQ==} - dependencies: - '@vue/shared': 3.2.47 - dev: false /@vue/reactivity@3.3.7: resolution: {integrity: sha512-cZNVjWiw00708WqT0zRpyAgduG79dScKEPYJXq2xj/aMtk3SKvL3FBt2QKUlh6EHBJ1m8RhBY+ikBUzwc7/khg==} dependencies: '@vue/shared': 3.3.7 - dev: true - - /@vue/runtime-core@3.2.47: - resolution: {integrity: sha512-RZxbLQIRB/K0ev0K9FXhNbBzT32H9iRtYbaXb0ZIz2usLms/D55dJR2t6cIEUn6vyhS3ALNvNthI+Q95C+NOpA==} - dependencies: - '@vue/reactivity': 3.2.47 - '@vue/shared': 3.2.47 - dev: false /@vue/runtime-core@3.3.7: resolution: {integrity: sha512-LHq9du3ubLZFdK/BP0Ysy3zhHqRfBn80Uc+T5Hz3maFJBGhci1MafccnL3rpd5/3wVfRHAe6c+PnlO2PAavPTQ==} dependencies: '@vue/reactivity': 3.3.7 '@vue/shared': 3.3.7 - dev: true - - /@vue/runtime-dom@3.2.47: - resolution: {integrity: sha512-ArXrFTjS6TsDei4qwNvgrdmHtD930KgSKGhS5M+j8QxXrDJYLqYw4RRcDy1bz1m1wMmb6j+zGLifdVHtkXA7gA==} - dependencies: - '@vue/runtime-core': 3.2.47 - '@vue/shared': 3.2.47 - csstype: 2.6.21 - dev: false /@vue/runtime-dom@3.3.7: resolution: {integrity: sha512-PFQU1oeJxikdDmrfoNQay5nD4tcPNYixUBruZzVX/l0eyZvFKElZUjW4KctCcs52nnpMGO6UDK+jF5oV4GT5Lw==} @@ -1818,17 +1778,6 @@ packages: '@vue/runtime-core': 3.3.7 '@vue/shared': 3.3.7 csstype: 3.1.2 - dev: true - - /@vue/server-renderer@3.2.47(vue@3.2.47): - resolution: {integrity: sha512-dN9gc1i8EvmP9RCzvneONXsKfBRgqFeFZLurmHOveL7oH6HiFXJw5OGu294n1nHc/HMgTy6LulU/tv5/A7f/LA==} - peerDependencies: - vue: 3.2.47 - dependencies: - '@vue/compiler-ssr': 3.2.47 - '@vue/shared': 3.2.47 - vue: 3.2.47 - dev: false /@vue/server-renderer@3.3.7(vue@3.3.7): resolution: {integrity: sha512-UlpKDInd1hIZiNuVVVvLgxpfnSouxKQOSE2bOfQpBuGwxRV/JqqTCyyjXUWiwtVMyeRaZhOYYqntxElk8FhBhw==} @@ -1838,14 +1787,22 @@ packages: '@vue/compiler-ssr': 3.3.7 '@vue/shared': 3.3.7 vue: 3.3.7(typescript@5.2.2) - dev: true - - /@vue/shared@3.2.47: - resolution: {integrity: sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==} - dev: false /@vue/shared@3.3.7: resolution: {integrity: sha512-N/tbkINRUDExgcPTBvxNkvHGu504k8lzlNQRITVnm6YjOjwa4r0nnbd4Jb01sNpur5hAllyRJzSK5PvB9PPwRg==} + + /@vue/test-utils@2.4.1(vue@3.3.7): + resolution: {integrity: sha512-VO8nragneNzUZUah6kOjiFmD/gwRjUauG9DROh6oaOeFwX1cZRUNHhdeogE8635cISigXFTtGLUQWx5KCb0xeg==} + peerDependencies: + '@vue/server-renderer': ^3.0.1 + vue: ^3.0.1 + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + dependencies: + js-beautify: 1.14.9 + vue: 3.3.7(typescript@5.2.2) + vue-component-type-helpers: 1.8.4 dev: true /@vueuse/core@10.5.0(vue@3.3.7): @@ -1923,6 +1880,10 @@ packages: - vue dev: true + /abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + dev: true + /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2021,6 +1982,12 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true + /aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + dependencies: + deep-equal: 2.2.2 + dev: true + /array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} dependencies: @@ -2033,9 +2000,9 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.1.4 + define-properties: 1.2.1 es-abstract: 1.20.5 - get-intrinsic: 1.1.3 + get-intrinsic: 1.2.1 is-string: 1.0.7 dev: true @@ -2060,7 +2027,7 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.1.4 + define-properties: 1.2.1 es-abstract: 1.20.5 es-shim-unscopables: 1.0.0 dev: true @@ -2335,6 +2302,11 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true + /commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + dev: true + /comment-parser@1.4.0: resolution: {integrity: sha512-QLyTNiZ2KDOibvFPlZ6ZngVsZ/0gYnE6uTXi5aoDg8ed3AkJAz4sEje3Y8a29hQ1s6A99MZXe47fLAXQ1rTqaw==} engines: {node: '>= 12.0.0'} @@ -2348,6 +2320,13 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + dev: true + /consola@3.2.3: resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} engines: {node: ^14.18.0 || >=16.10.0} @@ -2383,13 +2362,8 @@ packages: engines: {node: '>=4'} dev: true - /csstype@2.6.21: - resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==} - dev: false - /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} - dev: true /csv-generate@3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} @@ -2467,6 +2441,29 @@ packages: type-detect: 4.0.8 dev: true + /deep-equal@2.2.2: + resolution: {integrity: sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA==} + dependencies: + array-buffer-byte-length: 1.0.0 + call-bind: 1.0.2 + es-get-iterator: 1.1.3 + get-intrinsic: 1.2.1 + is-arguments: 1.1.1 + is-array-buffer: 3.0.2 + is-date-object: 1.0.5 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.2 + isarray: 2.0.5 + object-is: 1.1.5 + object-keys: 1.1.1 + object.assign: 4.1.4 + regexp.prototype.flags: 1.5.1 + side-channel: 1.0.4 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.1 + which-typed-array: 1.1.11 + dev: true + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -2491,14 +2488,6 @@ packages: has-property-descriptors: 1.0.0 dev: true - /define-properties@1.1.4: - resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==} - engines: {node: '>= 0.4'} - dependencies: - has-property-descriptors: 1.0.0 - object-keys: 1.1.1 - dev: true - /define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -2576,6 +2565,21 @@ packages: esutils: 2.0.3 dev: true + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.5.4 + dev: true + /electron-to-chromium@1.4.503: resolution: {integrity: sha512-LF2IQit4B0VrUHFeQkWhZm97KuJSGF2WJqq1InpY+ECpFRkXd8yTIaTtJxsO0OKDmiBYwWqcrNaXOurn2T2wiA==} dev: true @@ -2624,17 +2628,17 @@ packages: has: 1.0.3 has-property-descriptors: 1.0.0 has-symbols: 1.0.3 - internal-slot: 1.0.4 + internal-slot: 1.0.5 is-callable: 1.2.7 is-negative-zero: 2.0.2 is-regex: 1.1.4 is-shared-array-buffer: 1.0.2 is-string: 1.0.7 is-weakref: 1.0.2 - object-inspect: 1.12.2 + object-inspect: 1.12.3 object-keys: 1.1.1 object.assign: 4.1.4 - regexp.prototype.flags: 1.4.3 + regexp.prototype.flags: 1.5.1 safe-regex-test: 1.0.0 string.prototype.trimend: 1.0.6 string.prototype.trimstart: 1.0.6 @@ -2686,6 +2690,20 @@ packages: which-typed-array: 1.1.11 dev: true + /es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + is-arguments: 1.1.1 + is-map: 2.0.2 + is-set: 2.0.2 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.0.0 + dev: true + /es-set-tostringtag@2.0.1: resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} engines: {node: '>= 0.4'} @@ -3357,14 +3375,6 @@ packages: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} dev: true - /get-intrinsic@1.1.3: - resolution: {integrity: sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==} - dependencies: - function-bind: 1.1.1 - has: 1.0.3 - has-symbols: 1.0.3 - dev: true - /get-intrinsic@1.2.1: resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} dependencies: @@ -3616,13 +3626,8 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: true - /internal-slot@1.0.4: - resolution: {integrity: sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==} - engines: {node: '>= 0.4'} - dependencies: - get-intrinsic: 1.2.1 - has: 1.0.3 - side-channel: 1.0.4 + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} dev: true /internal-slot@1.0.5: @@ -3643,6 +3648,14 @@ packages: tslib: 2.4.1 dev: false + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: true + /is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} dependencies: @@ -3723,6 +3736,10 @@ packages: is-extglob: 2.1.1 dev: true + /is-map@2.0.2: + resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + dev: true + /is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} dev: true @@ -3778,6 +3795,10 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-set@2.0.2: + resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + dev: true + /is-shared-array-buffer@1.0.2: resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} dependencies: @@ -3812,12 +3833,23 @@ packages: which-typed-array: 1.1.11 dev: true + /is-weakmap@2.0.1: + resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + dev: true + /is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: call-bind: 1.0.2 dev: true + /is-weakset@2.0.2: + resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + dev: true + /is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -3835,6 +3867,17 @@ packages: resolution: {integrity: sha512-5eEbBDQT/jF1xg6l36P+mWGGoH9Spuy0PCdSr2dtWRDGC6ph/w9ZCL4lmESW8f8F7MwT3XKescfP0wnZWAKL9w==} dev: true + /js-beautify@1.14.9: + resolution: {integrity: sha512-coM7xq1syLcMyuVGyToxcj2AlzhkDjmfklL8r0JgJ7A76wyGMpJ1oA35mr4APdYNO/o/4YY8H54NQIJzhMbhBg==} + engines: {node: '>=12'} + hasBin: true + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 8.1.0 + nopt: 6.0.0 + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} requiresBuild: true @@ -3990,11 +4033,10 @@ packages: yallist: 4.0.0 dev: true - /magic-string@0.25.9: - resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} - dependencies: - sourcemap-codec: 1.4.8 - dev: false + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true /magic-string@0.27.0: resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} @@ -4015,7 +4057,6 @@ packages: engines: {node: '>=12'} dependencies: '@jridgewell/sourcemap-codec': 1.4.15 - dev: true /map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} @@ -4297,6 +4338,13 @@ packages: brace-expansion: 2.0.1 dev: true + /minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -4374,6 +4422,14 @@ packages: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} dev: true + /nopt@6.0.0: + resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + dependencies: + abbrev: 1.1.1 + dev: true + /normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: @@ -4399,14 +4455,18 @@ packages: boolbase: 1.0.0 dev: true - /object-inspect@1.12.2: - resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} - dev: true - /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} dev: true + /object-is@1.1.5: + resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.1 + dev: true + /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4445,7 +4505,7 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.1.4 + define-properties: 1.2.1 es-abstract: 1.20.5 dev: true @@ -4625,15 +4685,6 @@ packages: util-deprecate: 1.0.2 dev: true - /postcss@8.4.23: - resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 - dev: false - /postcss@8.4.30: resolution: {integrity: sha512-7ZEao1g4kd68l97aWG/etQKPKq07us0ieSZ2TnFDk11i0ZfDW2AwKHYU8qv4MZKqN2fdBfg+7q0ES06UA73C1g==} engines: {node: ^10 || ^12 || >=14} @@ -4650,7 +4701,6 @@ packages: nanoid: 3.3.6 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /preact@10.13.0: resolution: {integrity: sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw==} @@ -4700,6 +4750,15 @@ packages: engines: {node: ^14.13.1 || >=16.0.0} dev: true + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4709,6 +4768,10 @@ packages: react-is: 18.2.0 dev: true + /proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + dev: true + /pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} dev: true @@ -4732,6 +4795,10 @@ packages: engines: {node: '>=10'} dev: true + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true @@ -4808,15 +4875,6 @@ packages: resolution: {integrity: sha512-s2aEVuLhvnVJW6s/iPgEGK6R+/xngd2jNQ+xy4bXNDKxZKJH6jpPHY6kVeVv1IeLCHgswRj+Kl3ELaDjG6V1iw==} dev: true - /regexp.prototype.flags@1.4.3: - resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} - engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - dev: true - /regexp.prototype.flags@1.5.1: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} engines: {node: '>= 0.4'} @@ -4939,15 +4997,18 @@ packages: /semver@5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} + hasBin: true dev: true /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true dev: true /semver@7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} engines: {node: '>=10'} + hasBin: true dependencies: lru-cache: 6.0.0 dev: true @@ -5040,15 +5101,6 @@ packages: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} - /source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - dev: false - - /sourcemap-codec@1.4.8: - resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} - dev: false - /spawndamnit@2.0.0: resolution: {integrity: sha512-j4JKEcncSjFlqIwU5L/rp2N5SIPsdxaRsIv678+TZxZ0SRDJTm8JrxJMjE/XuiEZNEir3S8l0Fa3Ke339WI4qA==} dependencies: @@ -5090,6 +5142,13 @@ packages: resolution: {integrity: sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q==} dev: true + /stop-iteration-iterator@1.0.0: + resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} + engines: {node: '>= 0.4'} + dependencies: + internal-slot: 1.0.5 + dev: true + /stream-transform@2.1.3: resolution: {integrity: sha512-9GHUiM5hMiCi6Y03jD2ARC1ettBXkQBoQAe7nJsPknnI0ow10aXjTnew8QtYQmLjzn974BnmWEAJgCY6ZP1DeQ==} dependencies: @@ -5711,6 +5770,10 @@ packages: resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} dev: true + /vue-component-type-helpers@1.8.4: + resolution: {integrity: sha512-6bnLkn8O0JJyiFSIF0EfCogzeqNXpnjJ0vW/SZzNHfe6sPx30lTtTXlE5TFs2qhJlAtDFybStVNpL73cPe3OMQ==} + dev: true + /vue-demi@0.14.6(vue@3.3.7): resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} engines: {node: '>=12'} @@ -5744,16 +5807,6 @@ packages: - supports-color dev: true - /vue@3.2.47: - resolution: {integrity: sha512-60188y/9Dc9WVrAZeUVSDxRQOZ+z+y5nO2ts9jWXSTkMvayiWxCWOWtBQoYjLeccfXkiiPZWAHcV+WTPhkqJHQ==} - dependencies: - '@vue/compiler-dom': 3.2.47 - '@vue/compiler-sfc': 3.2.47 - '@vue/runtime-dom': 3.2.47 - '@vue/server-renderer': 3.2.47(vue@3.2.47) - '@vue/shared': 3.2.47 - dev: false - /vue@3.3.7(typescript@5.2.2): resolution: {integrity: sha512-YEMDia1ZTv1TeBbnu6VybatmSteGOS3A3YgfINOfraCbf85wdKHzscD6HSS/vB4GAtI7sa1XPX7HcQaJ1l24zA==} peerDependencies: @@ -5768,7 +5821,6 @@ packages: '@vue/server-renderer': 3.3.7(vue@3.3.7) '@vue/shared': 3.3.7 typescript: 5.2.2 - dev: true /wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -5803,6 +5855,15 @@ packages: is-symbol: 1.0.4 dev: true + /which-collection@1.0.1: + resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + dependencies: + is-map: 2.0.2 + is-set: 2.0.2 + is-weakmap: 2.0.1 + is-weakset: 2.0.2 + dev: true + /which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} dev: true diff --git a/src/components.ts b/src/components.ts deleted file mode 100644 index 52adbbf..0000000 --- a/src/components.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { IntlFormatted } from './IntlFormatted.js' - -export { Fragment } from './utils/Fragment.js' diff --git a/src/components/dateTime.ts b/src/components/dateTime.ts new file mode 100644 index 0000000..5c65af6 --- /dev/null +++ b/src/components/dateTime.ts @@ -0,0 +1,38 @@ +import { + definePartsFormatterComponent, + defineSimpleFormatterComponent, + type PartsFormatterComponentProps, + type PartsFormatterComponentSlots, + type SimpleFormatterComponentProps, + type SimpleFormatterComponentSlots, +} from './utils/index.ts' + +export const FormattedDate = defineSimpleFormatterComponent('formatDate') + +export type FormattedDateProps = SimpleFormatterComponentProps<'formatDate'> + +export type FormattedDateSlots = SimpleFormatterComponentSlots<'formatDate'> + +export const FormattedDateParts = + definePartsFormatterComponent('formatDateToParts') + +export type FormattedDatePartsProps = + PartsFormatterComponentProps<'formatDateToParts'> + +export type FormattedDatePartsSlots = + PartsFormatterComponentSlots<'formatDateToParts'> + +export const FormattedTime = defineSimpleFormatterComponent('formatTime') + +export type FormattedTimeProps = SimpleFormatterComponentProps<'formatTime'> + +export type FormattedTimeSlots = SimpleFormatterComponentSlots<'formatTime'> + +export const FormattedTimeParts = + definePartsFormatterComponent('formatTimeToParts') + +export type FormattedTimePartsProps = + PartsFormatterComponentProps<'formatTimeToParts'> + +export type FormattedTimePartsSlots = + PartsFormatterComponentSlots<'formatTimeToParts'> diff --git a/src/components/dateTimeRange.ts b/src/components/dateTimeRange.ts new file mode 100644 index 0000000..740c9af --- /dev/null +++ b/src/components/dateTimeRange.ts @@ -0,0 +1,67 @@ +import type { FormatDateOptions } from '@formatjs/intl' +import { + computed, + createTextVNode, + defineComponent, + type PropType, + type SetupContext, + type SlotsType, +} from 'vue' +import { useVIntl } from '../runtime/index.ts' +import { normalizeAttrs } from './utils/index.ts' + +interface FormattedDateTimeRangeDefinedProps { + from: Date | number + to: Date | number +} + +export interface FormattedDateTimeRangeProps + extends FormattedDateTimeRangeDefinedProps, + FormatDateOptions {} + +export interface FormattedDateTimeRangeSlots { + default(props: { formattedValue: string }): any +} + +export const FormattedDateTimeRange = defineComponent( + ( + props: FormattedDateTimeRangeProps, + ctx: SetupContext<{}, SlotsType>>, + ) => { + const vintl = useVIntl() + + const $options = computed( + () => normalizeAttrs(ctx.attrs) as FormatDateOptions, + ) + + return () => { + const { from, to } = props as FormattedDateTimeRangeDefinedProps + + const formattedValue = vintl.intl.formatDateTimeRange( + from, + to, + $options.value, + ) + + return ( + ctx.slots.default?.({ formattedValue }) ?? + createTextVNode(formattedValue) + ) + } + }, + { + inheritAttrs: false, + props: { + from: { + required: true, + type: [Number, Date] as PropType, + default: undefined, + }, + to: { + required: true, + type: [Number, Date] as PropType, + default: undefined, + }, + }, + }, +) diff --git a/src/components/displayName.ts b/src/components/displayName.ts new file mode 100644 index 0000000..7da4c55 --- /dev/null +++ b/src/components/displayName.ts @@ -0,0 +1,14 @@ +import { + defineSimpleFormatterComponent, + type SimpleFormatterComponentProps, + type SimpleFormatterComponentSlots, +} from './utils/index.ts' + +export const FormattedDisplayName = + defineSimpleFormatterComponent('formatDisplayName') + +export type FormattedDisplayNameProps = + SimpleFormatterComponentProps<'formatDisplayName'> + +export type FormattedDisplayNameSlots = + SimpleFormatterComponentSlots<'formatDisplayName'> diff --git a/src/components/fragment.ts b/src/components/fragment.ts new file mode 100644 index 0000000..9504771 --- /dev/null +++ b/src/components/fragment.ts @@ -0,0 +1,13 @@ +import { defineComponent, type VNodeChild } from 'vue' + +interface FragmentProps { + of: VNodeChild +} + +export const Fragment = defineComponent( + (props: FragmentProps) => () => props.of, + { + // eslint-disable-next-line vue/require-prop-types + props: ['of'], + }, +) diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..1fdd151 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,11 @@ +export * from './dateTime.ts' +export * from './dateTimeRange.ts' +export * from './displayName.ts' +export * from './fragment.ts' +export * from './intlFormatted.ts' +export * from './lists.ts' +export * from './listsParts.ts' +export * from './message.ts' +export * from './number.ts' +export * from './plural.ts' +export * from './relativeTime.ts' diff --git a/src/IntlFormatted.ts b/src/components/intlFormatted.ts similarity index 95% rename from src/IntlFormatted.ts rename to src/components/intlFormatted.ts index 9f74d01..083b45f 100644 --- a/src/IntlFormatted.ts +++ b/src/components/intlFormatted.ts @@ -12,16 +12,13 @@ import type { MessageID, MessageValues, MessageValueType, -} from './types/messages.js' -import { useI18n } from './runtime/useI18n.js' +} from '../types/messages.ts' +import { useVIntl } from '../runtime/index.ts' +import { createRecord } from './utils/index.ts' /** Represents a value that can be either `T` or an array of `T`. */ type MaybeArray = T | T[] -function createObject() { - return Object.create(null) -} - interface CommonProps { tags?: string[] } @@ -79,10 +76,10 @@ export function IntlFormatted( ) } - const { intl, normalizeMessageDescriptor: getDescriptor } = useI18n() + const { intl, normalizeMessageDescriptor: getDescriptor } = useVIntl() /** Initial values are passed to the slots. */ - const initialValues: MessageValues = createObject() + const initialValues: MessageValues = createRecord() /** * Provided values are values that were automatically provided by the @@ -90,7 +87,7 @@ export function IntlFormatted( * * Initial values are to be merged before assigning provided values. */ - const values: MessageValues = createObject() + const values: MessageValues = createRecord() if (props.values != null) { Object.assign(initialValues, props.values) diff --git a/src/components/lists.ts b/src/components/lists.ts new file mode 100644 index 0000000..3d64c79 --- /dev/null +++ b/src/components/lists.ts @@ -0,0 +1,59 @@ +import type { FormatListOptions } from '@formatjs/intl' +import { + computed, + defineComponent, + type PropType, + type SetupContext, + type SlotsType, + type VNode, +} from 'vue' +import { useVIntl } from '../runtime/index.ts' +import { normalizeAttrs } from './utils/index.ts' + +interface FormattedListDefinedProps { + items: readonly Item[] +} + +export type FormattedListProps = + FormattedListDefinedProps & FormatListOptions + +export interface FormattedListSlots< + Item extends string | VNode = string | VNode, +> { + default(props: { + children: Item extends string ? string : string | Item | (string | Item)[] + }): any +} + +export const FormattedList = defineComponent( + ( + props: FormattedListProps, + ctx: SetupContext<{}, SlotsType>>>, + ) => { + const vintl = useVIntl() + + const options = computed(() => { + return normalizeAttrs(ctx.attrs) as FormatListOptions + }) + + return () => { + const { items } = props as FormattedListDefinedProps + + const children = vintl.intl.formatList(items, options.value) as any + + return ctx.slots.default?.({ children }) ?? children + } + }, + { + inheritAttrs: false, + props: { + items: { + type: Array as PropType, + default() { + return [] + }, + }, + }, + }, +) as (props: FormattedListProps) => any +// override because typescript is stupid diff --git a/src/components/listsParts.ts b/src/components/listsParts.ts new file mode 100644 index 0000000..de78173 --- /dev/null +++ b/src/components/listsParts.ts @@ -0,0 +1,61 @@ +import type { FormatListOptions, IntlFormatters } from '@formatjs/intl' +import { + type VNode, + defineComponent, + type SetupContext, + type SlotsType, + computed, + type PropType, +} from 'vue' +import { useVIntl } from '../runtime/index.ts' +import { normalizeAttrs } from './utils/index.ts' + +interface FormattedListPartsDefinedProps { + items: readonly Item[] +} + +export interface FormattedListPartsProps + extends FormattedListPartsDefinedProps, + FormatListOptions {} + +export interface FormattedListPartsSlots { + default(props: { + parts: ReturnType['formatListToParts']> + }): any +} + +export const FormattedListParts = defineComponent( + ( + props: FormattedListPartsProps, + ctx: SetupContext<{}, SlotsType>>>, + ) => { + const vintl = useVIntl() + + const options = computed(() => { + return normalizeAttrs(ctx.attrs) as FormatListOptions + }) + + return () => { + const { items } = props as FormattedListPartsDefinedProps + + const parts = vintl.intl.formatListToParts(items, options.value) + + const defaultSlot = ctx.slots.default + + return defaultSlot == null + ? parts.map((part) => part.value) + : defaultSlot({ parts }) + } + }, + { + inheritAttrs: false, + props: { + items: { + type: Array as PropType, + default() { + return [] + }, + }, + }, + }, +) as (props: FormattedListPartsProps) => any diff --git a/src/components/message.ts b/src/components/message.ts new file mode 100644 index 0000000..50ec2f1 --- /dev/null +++ b/src/components/message.ts @@ -0,0 +1,163 @@ +import { + computed, + defineComponent, + type SlotsType, + type SetupContext, + type VNodeArrayChildren, +} from 'vue' +import type { FormatXMLElementFn, PrimitiveType } from 'intl-messageformat' +import { useVIntl } from '../runtime/index.ts' +import { type MessageContent, type MessageValueType } from '../types/index.ts' +import { createRecord } from './utils/index.ts' + +function isValueSlotName(slotName: string): slotName is `~${string}` { + return slotName.startsWith('~') +} + +type ValuesRecord = Record< + string, + PrimitiveType | T | FormatXMLElementFn +> + +export interface FormattedMessageProps { + id: string + description?: string | object + defaultMessage?: MessageContent + values?: ValuesRecord +} + +export interface FormattedMessageSlots { + [key: string]: (ctx: { + children: (T | string)[] + values: ValuesRecord + }) => string | T | (string | T)[] + [key: `~${string}`]: (ctx: { values: ValuesRecord }) => string | T +} + +class SlotOutput { + constructor(public readonly value: T | T[]) {} +} + +type NonArray = T extends any[] ? never : T +type VNodeChildAtom = NonArray +type VNodeArrayChildrenWith = ( + | T + | VNodeChildAtom + | VNodeArrayChildrenWith +)[] + +function normalizeOutput( + rawOutput: + | string + | SlotOutput + | T + | (string | SlotOutput | T)[], +): VNodeArrayChildrenWith { + if (Array.isArray(rawOutput)) { + const output: VNodeArrayChildrenWith = [] + for (const child of rawOutput) { + if (child instanceof SlotOutput) { + if (Array.isArray(child.value)) { + output.push(...child.value) + } else { + output.push(child.value) + } + } else { + output.push(child) + } + } + return output + } else if (rawOutput instanceof SlotOutput) { + return Array.isArray(rawOutput.value) ? rawOutput.value : [rawOutput.value] + } else if (typeof rawOutput === 'string') { + return [rawOutput] + } + + return [rawOutput] +} + +export const FormattedMessage = defineComponent( + function FormattedMessage( + props: FormattedMessageProps, + ctx: SetupContext<{}, SlotsType>>>, + ) { + const descriptor = computed(() => ({ + id: props.id, + defaultMessage: props.defaultMessage, + description: props.description, + })) + + const values = computed>(() => { + const combinedValues = createRecord() as ValuesRecord + Object.assign(combinedValues, props.values) + + const slotValues = createRecord() as ValuesRecord< + T | SlotOutput | ((children: (T | string)[]) => SlotOutput) + > + + const { slots } = ctx + + for (const slotKey in slots) { + if (slots[slotKey] == null) continue + + if (isValueSlotName(slotKey)) { + slotValues[slotKey.slice(1)] = new SlotOutput( + slots[slotKey]!({ + values: combinedValues, + }), + ) + } else { + slotValues[slotKey] = (children: (T | string)[]) => + new SlotOutput( + slots[slotKey]!({ + values: combinedValues, + children, + }), + ) + } + } + + Object.assign(combinedValues, slotValues) + + return combinedValues + }) + + const vintl = useVIntl() + + return () => { + const output = vintl.intl.formatMessage( + descriptor.value, + values.value as any, + ) + + return normalizeOutput(output) + } + }, + { + props: { + id: { + type: String, + required: true, + default: undefined, + }, + defaultMessage: { + type: String, + required: false, + default: undefined, + }, + description: { + type: String, + required: false, + default: undefined, + }, + values: { + type: Object, + required: false, + default() { + return {} + }, + }, + }, + slots: Object as any, + }, +) as (props: FormattedMessageProps) => any diff --git a/src/components/number.ts b/src/components/number.ts new file mode 100644 index 0000000..f6477c3 --- /dev/null +++ b/src/components/number.ts @@ -0,0 +1,27 @@ +import { + defineSimpleFormatterComponent, + definePartsFormatterComponent, + type SimpleFormatterComponentSlots, + type SimpleFormatterComponentProps, + type PartsFormatterComponentProps, + type PartsFormatterComponentSlots, +} from './utils/index.ts' + +// This is required so that TypeScript cannot infer the types. +import type {} from '@formatjs/ecma402-abstract' + +export const FormattedNumber = defineSimpleFormatterComponent('formatNumber') + +export type FormattedNumberProps = SimpleFormatterComponentProps<'formatNumber'> + +export type FormattedNumberSlots = SimpleFormatterComponentSlots<'formatNumber'> + +export const FormattedNumberParts = definePartsFormatterComponent( + 'formatNumberToParts', +) + +export type FormattedNumberPartsProps = + PartsFormatterComponentProps<'formatNumberToParts'> + +export type FormattedNumberPartsSlots = + PartsFormatterComponentSlots<'formatNumberToParts'> diff --git a/src/components/plural.ts b/src/components/plural.ts new file mode 100644 index 0000000..53a0804 --- /dev/null +++ b/src/components/plural.ts @@ -0,0 +1,62 @@ +import type { FormatPluralOptions, IntlFormatters } from '@formatjs/intl' +import { + computed, + defineComponent, + type SetupContext, + type SlotsType, +} from 'vue' +import { useVIntl } from '../runtime/index.ts' +import { normalizeAttrs } from './utils/index.ts' + +type PluralSelectors = ReturnType + +type PluralSlots = { + [K in PluralSelectors]?: (props: { value: number }) => any +} + +interface FormattedPluralDefinedProps { + value: number +} + +export interface FormattedPluralProps + extends FormattedPluralDefinedProps, + FormatPluralOptions {} + +export interface FormattedPluralSlots extends PluralSlots { + default?(props: { children: string | T | (string | T)[] }): any +} + +export const FormattedPlural = defineComponent( + ( + props: FormattedPluralProps, + ctx: SetupContext<{}, SlotsType>>>, + ) => { + const vintl = useVIntl() + + const $options = computed(() => { + return normalizeAttrs(ctx.attrs) as FormatPluralOptions + }) + + return () => { + const { value } = props + + const rule = vintl.intl.formatPlural(value, $options.value) + + const ruleSlot = ctx.slots[rule] ?? ctx.slots.other + + const ruleRender = ruleSlot != null ? ruleSlot({ value }) : [] + + return ctx.slots.default?.({ children: ruleRender }) ?? ruleRender + } + }, + { + inheritAttrs: false, + props: { + value: { + type: Number, + required: false, + default: 0, + }, + }, + }, +) as (props: FormattedPluralProps) => any diff --git a/src/components/relativeTime.ts b/src/components/relativeTime.ts new file mode 100644 index 0000000..7d800d4 --- /dev/null +++ b/src/components/relativeTime.ts @@ -0,0 +1,67 @@ +import type { FormatRelativeTimeOptions } from '@formatjs/intl' +import { + createTextVNode, + defineComponent, + type SetupContext, + type SlotsType, + type PropType, + computed, +} from 'vue' +import { useVIntl } from '../runtime/index.ts' +import { normalizeAttrs } from './utils/index.ts' + +interface RealProps { + value: number + unit: Intl.RelativeTimeFormatUnit +} + +export interface FormattedRelativeTimeProps + extends RealProps, + FormatRelativeTimeOptions {} + +export interface FormattedRelativeTimeSlots { + default(props: { formattedValue: string }): any +} + +export const FormattedRelativeTime = defineComponent( + ( + props: FormattedRelativeTimeProps, + ctx: SetupContext<{}, SlotsType>>, + ) => { + const vintl = useVIntl() + + const $options = computed( + () => normalizeAttrs(ctx.attrs) as FormatRelativeTimeOptions, + ) + + return () => { + const { value, unit } = props as RealProps + + const formattedValue = vintl.intl.formatRelativeTime( + value ?? 0, + unit, + $options.value, + ) + + return ( + ctx.slots.default?.({ formattedValue }) ?? + createTextVNode(formattedValue) + ) + } + }, + { + inheritAttrs: false, + props: { + unit: { + type: String as PropType, + required: false, + default: 'seconds', + }, + value: { + type: Number, + required: false, + default: 0, + }, + }, + }, +) diff --git a/src/components/utils/definersCommon.ts b/src/components/utils/definersCommon.ts new file mode 100644 index 0000000..c99b1d0 --- /dev/null +++ b/src/components/utils/definersCommon.ts @@ -0,0 +1,4 @@ +import type { IntlFormatters } from '@formatjs/intl' + +export type PartsFormattersKeys = + K extends `${string}Parts` ? K : never diff --git a/src/components/utils/index.ts b/src/components/utils/index.ts new file mode 100644 index 0000000..5a02f98 --- /dev/null +++ b/src/components/utils/index.ts @@ -0,0 +1,14 @@ +import { camelize } from '../../utils/strings.ts' + +export * from './simpleDefiner.ts' +export * from './partsDefiner.ts' + +export function normalizeAttrs(attrs: Record) { + const normalizedAttrs: Record = Object.create(null) + for (const key in attrs) normalizedAttrs[camelize(key)] = attrs[key] + return normalizedAttrs +} + +export function createRecord(): Record { + return Object.create(null) +} diff --git a/src/components/utils/partsDefiner.ts b/src/components/utils/partsDefiner.ts new file mode 100644 index 0000000..d998087 --- /dev/null +++ b/src/components/utils/partsDefiner.ts @@ -0,0 +1,75 @@ +import type { IntlFormatters } from '@formatjs/intl' +import { + computed, + createTextVNode, + defineComponent, + type SetupContext, + type SlotsType, +} from 'vue' +import { useVIntl } from '../../runtime/index.ts' +import type { MessageValueType } from '../../types/index.ts' +import type { PartsFormattersKeys } from './definersCommon.ts' +import { normalizeAttrs } from './index.ts' +import { formatterComponentName } from './simpleDefiner.ts' + +export interface PartsFormatterDefinedProps< + FormatterName extends PartsFormattersKeys, +> { + value: Parameters[FormatterName]>[0] +} + +export type PartsFormatterOptions = + NonNullable[FormatterName]>[1]> + +export type PartsFormatterComponentProps< + FormatterName extends PartsFormattersKeys, +> = PartsFormatterDefinedProps & + PartsFormatterOptions + +export type PartsFormatterComponentSlots< + FormatterName extends PartsFormattersKeys, +> = { + default?: (props: { parts: ReturnType }) => any +} + +export function definePartsFormatterComponent< + FormatterName extends PartsFormattersKeys, +>(name: FormatterName) { + return defineComponent( + ( + props: PartsFormatterComponentProps, + ctx: SetupContext< + {}, + SlotsType>> + >, + ) => { + const vintl = useVIntl() + + const $options = computed( + () => normalizeAttrs(ctx.attrs) as PartsFormatterOptions, + ) + + const $formatter = computed(() => vintl.intl[name]) + + return () => { + const parts = $formatter.value( + props.value as any, + $options.value as any, + ) as any + + return [ + ctx.slots.default?.({ parts }) ?? + createTextVNode( + (parts as { value: string }[]).map((part) => part.value).join(''), + ), + ] + } + }, + { + name: formatterComponentName(name), + inheritAttrs: false, + // eslint-disable-next-line vue/require-prop-types + props: ['value'], + }, + ) +} diff --git a/src/components/utils/simpleDefiner.ts b/src/components/utils/simpleDefiner.ts new file mode 100644 index 0000000..3127ef7 --- /dev/null +++ b/src/components/utils/simpleDefiner.ts @@ -0,0 +1,99 @@ +import type { IntlFormatters } from '@formatjs/intl' +import { + computed, + createTextVNode, + defineComponent, + type SetupContext, + type SlotsType, +} from 'vue' +import { useVIntl } from '../../runtime/index.ts' +import type { MessageValueType } from '../../types/index.ts' +import { normalizeAttrs } from './index.ts' +import type { PartsFormattersKeys } from './definersCommon.ts' + +export function formatterComponentName(name: string) { + return name.startsWith('format') + ? `Formatted${name.split('To').join('')}` + : `IntlFormatted$${name}` +} + +type NonUtilityFormattersKeys = K extends `$${string}` + ? never + : K + +type ComplexFormattersKeys = + | 'formatMessage' + | 'formatDateTimeRange' + | 'formatRelativeTime' + | 'formatPlural' + | 'formatList' + +type SimpleFormattersKeys = + K extends PartsFormattersKeys + ? never + : K extends ComplexFormattersKeys + ? never + : K + +export interface SimpleFormatterDefinedProps< + FormatterName extends SimpleFormattersKeys, +> { + value: Parameters[FormatterName]>[0] +} + +export type SimpleFormatterOptions = + NonNullable[FormatterName]>[1]> + +export type SimpleFormatterComponentProps< + FormatterName extends SimpleFormattersKeys, +> = SimpleFormatterDefinedProps & + SimpleFormatterOptions + +export type SimpleFormatterComponentSlots< + FormatterName extends SimpleFormattersKeys, +> = { + default?: (props: { + formattedValue: ReturnType + }) => any +} + +export function defineSimpleFormatterComponent< + FormatterName extends SimpleFormattersKeys, +>(name: FormatterName) { + return defineComponent( + ( + props: SimpleFormatterComponentProps, + ctx: SetupContext< + {}, + SlotsType>> + >, + ) => { + const vintl = useVIntl() + + const $options = computed( + () => + normalizeAttrs(ctx.attrs) as SimpleFormatterOptions, + ) + + const $formatter = computed(() => vintl.intl[name]) + + return () => { + const formattedValue = $formatter.value( + props.value as any, + $options.value as any, + ) as ReturnType + + return [ + ctx.slots.default?.({ formattedValue }) ?? + createTextVNode(formattedValue), + ] + } + }, + { + name: formatterComponentName(name), + inheritAttrs: false, + // eslint-disable-next-line vue/require-prop-types + props: ['value'], + }, + ) +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 060aa2a..c78c93f 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,4 +1,3 @@ -export { useI18n, useVIntl } from './useI18n.js' -export { useFormatters } from './useFormatters.js' -export { useTranslate } from './useTranslate.js' +export { useVIntl } from './useVIntl.js' +export { useMessages, useMessage } from './useMessages.js' export * from './defineMessages.js' diff --git a/src/runtime/useFormatters.ts b/src/runtime/useFormatters.ts deleted file mode 100644 index e2ad4d3..0000000 --- a/src/runtime/useFormatters.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FormatAliases } from '../partial/intl.js' -import type { MessageValueType } from '../types/messages.js' -import { useI18n } from './useI18n.js' - -/** - * A composable to retrieve the format function aliases from the installed - * controller in the current app. - * - * @deprecated Please use `const { formats } = useI18n()` instead. - * @throws If controller cannot be found in the current application or current - * application cannot be determined (called outside of `setup()` call). - */ -export function useFormatters(): FormatAliases { - return useI18n().formats -} diff --git a/src/runtime/useMessages.ts b/src/runtime/useMessages.ts new file mode 100644 index 0000000..dd46ad9 --- /dev/null +++ b/src/runtime/useMessages.ts @@ -0,0 +1,161 @@ +import type { MessageDescriptor as MessageDescriptorBase } from '@formatjs/intl' +import type { FormatXMLElementFn, PrimitiveType } from 'intl-messageformat' +import { computed, isRef, reactive, type ComputedRef, type Ref } from 'vue' +import type { IntlController } from '../controller.ts' +import type { MessageValueType } from '../index.ts' +import { useVIntl } from './useVIntl.ts' + +type MaybeRef = T | Ref + +type PrimitiveValuesRecord = MaybeRef<{ + [key: string]: MaybeRef> +}> + +type ValuesRecord = MaybeRef<{ + [key: string]: MaybeRef< + PrimitiveType | RichTypes | FormatXMLElementFn + > +}> + +interface MessageDescriptor extends MessageDescriptorBase { + /** + * A record of the values for arguments used in the message. Can contain Vue + * references, which will be unwrapped, or be a reference itself. + */ + values?: ValuesRecord +} + +type MessageDescriptorOutput< + Descriptor extends MessageDescriptor, + RichTypes, +> = [Descriptor['values']] extends [undefined] + ? string + : Descriptor['values'] extends PrimitiveValuesRecord + ? string + : Array | RichTypes | string + +type MessageDescriptorsRecord = Record< + string, + MessageDescriptor +> + +type MessageDescriptorsRecordOutput< + Descriptor extends MessageDescriptorsRecord, + RichTypes, +> = { + [K in keyof Descriptor]: MessageDescriptorOutput +} + +function formatMessage< + Descriptor extends MessageDescriptor, + RichTypes, +>( + message: Descriptor, + vintl: IntlController, +): MessageDescriptorOutput { + const values = Object.create(null) + const rawInputs = message.values + + if (isRef(rawInputs)) { + Object.assign(values, rawInputs.value) + } else if (rawInputs != null) { + for (const k in rawInputs) { + const input = rawInputs[k] + values[k] = isRef(input) ? input.value : input + } + } + + return vintl.intl.formatMessage(message, values) +} + +/** + * Accepts a plain object of extended message descriptors, which may contain + * `values` alongside the message declaration itself. It then creates an object + * where each descriptor is mapped to a formatted. The object is reactive and + * message properties will be updating when the language, messages or values in + * the messages change. + * + * You can use `toRef` or `useMessages` to create read-only references for + * individual. + * + * @example + * const messages = useMessages({ + * farewell: { + * id: 'farewell', + * defaultMessage: 'Goodbye, {user}!', + * values: { + * user: computed(() => user.value.displayName), + * }, + * }, + * richText: { + * id: 'rich-text', + * defaultMessage: 'This text is red.', + * values: { + * red(children) { + * return h('span', { style: { color: 'red' } }, [children]) + * }, + * }, + * }, + * }) + * + * console.log(messages.farewell) // 'Goodbye, Andrea Rees!' + * + * @param messages A record of message descriptors. + * @returns A reactive map of messages. + */ +export function useMessages< + Descriptor extends MessageDescriptorsRecord, + RichTypes = MessageValueType, +>(messages: Descriptor) { + const vintl = useVIntl() + + type PreOutput = { + [K in keyof MessageDescriptorsRecordOutput< + Descriptor, + RichTypes + >]: ComputedRef[K]> + } + + const target: PreOutput = Object.create(null) + + for (const key of Object.keys(messages) as (keyof Descriptor)[]) { + const message = messages[key] + + type Message = Descriptor[typeof key] + + target[key] = computed(() => + formatMessage(message, vintl), + ) + } + + return reactive(target) +} + +/** + * Accepts an extended message descriptor, which may contain `values` alongside + * the message declaration itself. It then returns a read-only reference that + * gets updated when the language, messages or the values for the message + * change. + * + * @example + * const helloMessage = useMessage({ + * id: 'hello', + * defaultMessage: 'Hello, {user}!', + * values: { + * user: computed(() => user.value.displayName), + * }, + * }) + * + * console.log(helloMessage.value) // 'Hello, Andrea Rees!' + * + * @param message A message descriptor. + * @returns A read-only reference to the actual formatted message. + */ +export function useMessage< + Descriptor extends MessageDescriptor, + RichTypes = MessageValueType, +>(message: Descriptor) { + const vintl = useVIntl() + + return computed(() => formatMessage(message, vintl)) +} diff --git a/src/runtime/useTranslate.ts b/src/runtime/useTranslate.ts deleted file mode 100644 index 657fd6f..0000000 --- a/src/runtime/useTranslate.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { TranslateFunction } from '../types/translateFunction.js' -import { useI18n } from './useI18n.js' - -/** - * A composable to retrieve function to format a message from the installed - * controller in the current app. - * - * @deprecated Please use `const { formatMessage } = useI18n()` instead. - * @throws If controller cannot be found in the current application or current - * application cannot be determined (called outside of `setup()` call). - */ -export function useTranslate(): TranslateFunction { - return useI18n().formatMessage -} diff --git a/src/runtime/useI18n.ts b/src/runtime/useVIntl.ts similarity index 72% rename from src/runtime/useI18n.ts rename to src/runtime/useVIntl.ts index d3a313d..79918d4 100644 --- a/src/runtime/useI18n.ts +++ b/src/runtime/useVIntl.ts @@ -20,13 +20,3 @@ export function useVIntl() { return controller as IntlController } - -/** - * Alias for {@link useVIntl}. - * - * @deprecated This composable name is deprecated and will be removed in next - * major version. Please use {@link useVIntl} instead. - */ -export function useI18n() { - return useVIntl() -} diff --git a/src/utils/Fragment.ts b/src/utils/Fragment.ts deleted file mode 100644 index 055ad89..0000000 --- a/src/utils/Fragment.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { VNode } from 'vue' - -interface Props { - of: VNode[] | VNode -} - -export function Fragment(props: Props) { - return Array.isArray(props.of) ? props.of : [props.of] -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4ff8df2..5e95e60 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1 @@ -export { Fragment } from './Fragment.js' +export { Fragment } from '../components/fragment.js' diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 0000000..c126138 --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,5 @@ +const camelRegExp = /-(\w)/g + +export function camelize(value: string) { + return value.replace(camelRegExp, (_, c) => c.toUpperCase()) +} diff --git a/test/components/FormattedList/index.test.ts b/test/components/FormattedList/index.test.ts new file mode 100644 index 0000000..55f4986 --- /dev/null +++ b/test/components/FormattedList/index.test.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeEach, afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { + createVIntlPlugin, + withAbnormalSpacesReplaced, +} from '../../utils/index.ts' +import { ListDisplay } from './listDisplay.tsx' + +describe('FormattedList', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk']) + const { plugin, controller, resetController } = vintl + + const { getByText, getByTestId } = render(ListDisplay, { + global: { plugins: [plugin] }, + }) + + let list: HTMLElement + + const refreshList = () => (list = getByTestId('list-display')) + + beforeEach(async () => { + await fireEvent.click(getByText('Reset')) + refreshList() + }) + + afterEach(resetController) + + it('renders', async () => { + expect(list.textContent).toMatchInlineSnapshot('"1, 2, or 3"') + + await fireEvent.click(getByText('Add item')) + expect(list.textContent).toMatchInlineSnapshot('"1, 2, 3, or 4"') + }) + + it('changes locale', async () => { + await controller.changeLocale('uk') + const content = withAbnormalSpacesReplaced(list.textContent!) + expect(content).toMatchInlineSnapshot('"1, 2 або 3"') + }) + + it('renders as a slot', async () => { + await fireEvent.click(getByText('Slots on')) + const content = refreshList().textContent + expect(content).toMatchInlineSnapshot('"List is: 1, 2, or 3"') + + const slot = getByTestId('list-slot') + expect(slot.textContent!).toMatchInlineSnapshot('"1, 2, or 3"') + }) + + it('renders JSX items', async () => { + await fireEvent.click(getByText('JSX on')) + + expect(list.querySelector('b')).toBeDefined() + + await fireEvent.click(getByText('Press me')) + }) +}) diff --git a/test/components/FormattedList/listDisplay.tsx b/test/components/FormattedList/listDisplay.tsx new file mode 100644 index 0000000..f16d41d --- /dev/null +++ b/test/components/FormattedList/listDisplay.tsx @@ -0,0 +1,65 @@ +import { computed, defineComponent, ref } from 'vue' +import { + FormattedList, + type FormattedListSlots, +} from '../../../dist/components' + +export const ListDisplay = defineComponent(() => { + const list = ref(['1', '2', '3']) + + let increment = 3 + + const addListItem = () => list.value.push(String(++increment)) + + const useSlots = ref(false) + const enableSlots = () => (useSlots.value = true) + + const useJSXNodes = ref(false) + const enableJSXNodes = () => (useJSXNodes.value = true) + + const listToRender = computed(() => { + return useJSXNodes.value + ? [...list.value, Bold, ] + : list.value + }) + + const reset = () => { + list.value = ['1', '2', '3'] + increment = 3 + useSlots.value = false + useJSXNodes.value = false + } + + return () => { + let display: JSX.Element + + if (useSlots.value) { + const slots: FormattedListSlots = { + default: ({ children }) => ( + <> + {'List is: '} + {children} + + ), + } + + display = ( + + {slots} + + ) + } else { + display = + } + + return ( + <> +

{display}

+ + + + + + ) + } +}) diff --git a/test/components/FormattedListParts/index.test.ts b/test/components/FormattedListParts/index.test.ts new file mode 100644 index 0000000..310c58e --- /dev/null +++ b/test/components/FormattedListParts/index.test.ts @@ -0,0 +1,62 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { + createVIntlPlugin, + withAbnormalSpacesReplaced, +} from '../../utils/index.ts' +import { ListPartsDisplay } from './listPartsDisplay.tsx' + +describe('FormattedListParts', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk']) + const { plugin, controller, resetController } = vintl + + const { getByText, getByTestId } = render(ListPartsDisplay, { + global: { plugins: [plugin] }, + }) + + let list: HTMLElement + + const refreshList = () => (list = getByTestId('list-display')) + + beforeEach(async () => { + await fireEvent.click(getByText('Reset')) + refreshList() + }) + + afterEach(resetController) + + it('renders', async () => { + expect(list.textContent).toMatchInlineSnapshot('"1, 2, or 3"') + + await fireEvent.click(getByText('Add item')) + expect(list.textContent).toMatchInlineSnapshot('"1, 2, 3, or 4"') + }) + + it('changes locale', async () => { + await controller.changeLocale('uk') + + const content = withAbnormalSpacesReplaced(list.textContent!) + expect(content).toMatchInlineSnapshot('"1, 2 або 3"') + }) + + it('renders as a slot', async () => { + await fireEvent.click(getByText('Slots on')) + + const content = refreshList().textContent + expect(content).toMatchInlineSnapshot('"List is: 1, 2, or 3"') + + const slot = getByTestId('list-slot') + expect(slot.textContent!).toMatchInlineSnapshot('"1, 2, or 3"') + }) + + it('renders JSX items', async () => { + await fireEvent.click(getByText('JSX on')) + + const bold = refreshList().querySelector('b') + expect(bold?.textContent).toMatchInlineSnapshot('"Bold"') + + expect(getByText('Press me')).toBeDefined() + }) +}) diff --git a/test/components/FormattedListParts/listPartsDisplay.tsx b/test/components/FormattedListParts/listPartsDisplay.tsx new file mode 100644 index 0000000..9ba3cd6 --- /dev/null +++ b/test/components/FormattedListParts/listPartsDisplay.tsx @@ -0,0 +1,79 @@ +import { computed, defineComponent, ref } from 'vue' +import { + FormattedListParts, + type FormattedListPartsSlots, +} from '../../../dist/components' + +export const ListPartsDisplay = defineComponent(() => { + const list = ref(['1', '2', '3']) + + let increment = 3 + + const addListItem = () => list.value.push(String(++increment)) + + const useSlots = ref(false) + const enableSlots = () => (useSlots.value = true) + + const useJSXNodes = ref(false) + const enableJSXNodes = () => (useJSXNodes.value = true) + + const listToRender = computed(() => { + return useJSXNodes.value + ? [...list.value, Bold, ] + : list.value + }) + + const reset = () => { + list.value = ['1', '2', '3'] + increment = 3 + useSlots.value = false + useJSXNodes.value = false + } + + return () => { + let display: JSX.Element + + if (useSlots.value) { + const slots: FormattedListPartsSlots = { + default: ({ parts }) => ( + <> + {'List is: '} +
    + {parts.map((part) => ( +
  • {part.value}
  • + ))} +
+ + ), + } + + display = ( + + {slots} + + ) + } else { + display = ( + + ) + } + + return ( + <> +

{display}

+ + + + + + ) + } +}) diff --git a/test/components/FormattedMessage/index.test.ts b/test/components/FormattedMessage/index.test.ts new file mode 100644 index 0000000..a27fdb8 --- /dev/null +++ b/test/components/FormattedMessage/index.test.ts @@ -0,0 +1,65 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { createVIntlPlugin } from '../../utils/index.ts' +import { messagesPayload, MessageDisplay } from './messageDisplay.tsx' + +describe('FormattedMessage', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk'], (e) => { + e.addMessages(messagesPayload?.[e.locale.tag] ?? {}) + }) + + const { plugin, controller, resetController } = vintl + + const { getByText, getByTestId } = render(MessageDisplay, { + global: { plugins: [plugin] }, + }) + + let display: HTMLElement + + const refreshDisplay = () => (display = getByTestId('message-display')) + + beforeEach(async () => { + await fireEvent.click(getByText('Reset')) + refreshDisplay() + }) + + afterEach(async () => { + await resetController() + }) + + const content = () => display.textContent + + it('renders', async () => { + expect(content()).toMatchInlineSnapshot( + '"Hello, Oleksandr. You have 1 new message"', + ) + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot( + '"Hello, Oleksandr. You have 2 new messages"', + ) + }) + + it('changes locale', async () => { + await controller.changeLocale('uk') + expect(content()).toMatchInlineSnapshot( + '"Привіт, Oleksandr. У вас 1 нове повідомлення"', + ) + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot( + '"Привіт, Oleksandr. У вас 2 нових повідомлень"', + ) + }) + + it('renders with slots', async () => { + await fireEvent.click(getByText('Slots on')) + refreshDisplay() + + expect(display.innerHTML).toMatchInlineSnapshot( + '"Hello, Oleksandr 👋 You have 1 new message"', + ) + }) +}) diff --git a/test/components/FormattedMessage/messageDisplay.tsx b/test/components/FormattedMessage/messageDisplay.tsx new file mode 100644 index 0000000..28d5649 --- /dev/null +++ b/test/components/FormattedMessage/messageDisplay.tsx @@ -0,0 +1,89 @@ +import { defineMessages } from '@formatjs/intl' +import { defineComponent, ref } from 'vue' +import { + FormattedMessage, + type FormattedMessageSlots, +} from '../../../dist/components' + +const messages = defineMessages({ + greeting: { + id: 'greeting', + defaultMessage: + 'Hello, {name}. You have {count, plural, one {# new message} other {# new messages}}', + }, + greetingBold: { + id: 'greeting.bold', + defaultMessage: + 'Hello, {name} {wave_emoji} You have {count, plural, one {# new message} other {# new messages}}', + }, +} as const) + +export const messagesPayload: Record< + string, + { + [K in (typeof messages)[keyof typeof messages]['id']]: string + } +> = { + 'en-US': { + greeting: messages.greeting.defaultMessage, + 'greeting.bold': messages.greetingBold.defaultMessage, + }, + uk: { + greeting: + 'Привіт, {name}. У вас {count, plural, one {# нове повідомлення} other {# нових повідомлень}}', + 'greeting.bold': + 'Привіт, {name} {wave_emoji} У вас {count, plural, one {# нове повідомлення} other {# нових повідомлень}}', + }, +} as const + +export const MessageDisplay = defineComponent(() => { + const name = ref('Oleksandr') + + const unreadMessages = ref(1) + const incrementByOne = () => (unreadMessages.value += 1) + + const useSlots = ref(false) + const enableSlots = () => (useSlots.value = true) + + const reset = () => { + name.value = 'Oleksandr' + unreadMessages.value = 1 + useSlots.value = false + } + + return () => { + let display: JSX.Element + + if (useSlots.value) { + const slots: FormattedMessageSlots = { + '~wave_emoji': () => 👋, + bold: ({ children }) => {children}, + } + + display = ( + + {slots} + + ) + } else { + display = ( + + ) + } + + return ( + <> +

{display}

+ + + + + ) + } +}) diff --git a/test/components/FormattedNumber/counter.tsx b/test/components/FormattedNumber/counter.tsx new file mode 100644 index 0000000..21d8733 --- /dev/null +++ b/test/components/FormattedNumber/counter.tsx @@ -0,0 +1,54 @@ +import { defineComponent, ref } from 'vue' +import { + FormattedNumber, + type FormattedNumberSlots, +} from '../../../dist/components' + +export const Counter = defineComponent(() => { + const count = ref(0) + const incrementByOne = () => (count.value += 1) + const incrementByThousand = () => (count.value += 1000) + + const useSlots = ref(false) + const enableSlots = () => { + useSlots.value = true + } + + const reset = () => { + count.value = 0 + useSlots.value = false + } + + return () => { + let display: JSX.Element + + if (useSlots.value) { + const slots: FormattedNumberSlots = { + default: ({ formattedValue }) => ( + <> + {'Count is: '} + {formattedValue} + + ), + } + + display = ( + + {slots} + + ) + } else { + display = + } + + return ( + <> +

{display}

+ + + + + + ) + } +}) diff --git a/test/components/FormattedNumber/index.test.ts b/test/components/FormattedNumber/index.test.ts new file mode 100644 index 0000000..e1b3cf4 --- /dev/null +++ b/test/components/FormattedNumber/index.test.ts @@ -0,0 +1,70 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { + createVIntlPlugin, + withAbnormalSpacesReplaced, +} from '../../utils/index.ts' +import { Counter } from './counter.tsx' + +describe('FormattedNumber', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk']) + const { plugin, controller, resetController } = vintl + + const { getByText, getByTestId } = render(Counter, { + global: { plugins: [plugin] }, + }) + + let counter: HTMLElement + const refreshCounter = () => (counter = getByTestId('counter')) + + beforeEach(async () => { + await fireEvent.click(getByText('Reset')) + refreshCounter() + }) + + afterEach(resetController) + + const content = () => withAbnormalSpacesReplaced(counter.textContent!) + + it('renders', async () => { + expect(content()).toMatchInlineSnapshot('"0"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"1"') + + await fireEvent.click(getByText('+1000')) + expect(content()).toMatchInlineSnapshot('"1K"') + }) + + it('changes locale', async () => { + await controller.changeLocale('uk') + + expect(content()).toMatchInlineSnapshot('"0"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"1"') + + await fireEvent.click(getByText('+1000')) + expect(content()).toMatchInlineSnapshot('"1 тис."') + }) + + it('renders as a slot', async () => { + await fireEvent.click(getByText('Slots on')) + + refreshCounter() + + expect(content()).toMatchInlineSnapshot('"Count is: 0"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"Count is: 1"') + + await fireEvent.click(getByText('+1000')) + expect(content()).toMatchInlineSnapshot('"Count is: 1K"') + + const slot = getByTestId('counter-slot') + const slotContent = withAbnormalSpacesReplaced(slot.textContent!) + expect(slotContent).toMatchInlineSnapshot('"1K"') + }) +}) diff --git a/test/components/FormattedNumberParts/counter.tsx b/test/components/FormattedNumberParts/counter.tsx new file mode 100644 index 0000000..f5f390a --- /dev/null +++ b/test/components/FormattedNumberParts/counter.tsx @@ -0,0 +1,43 @@ +import { defineComponent, ref } from 'vue' +import { + FormattedNumberParts, + type FormattedNumberPartsSlots, +} from '../../../dist/components' + +export const Counter = defineComponent(() => { + const count = ref(0) + + const incrementByOne = () => { + count.value++ + } + + const incrementByThousand = () => { + count.value += 1000 + } + + const reset = () => { + count.value = 0 + } + + return () => { + const slots: FormattedNumberPartsSlots = { + default: ({ parts }) => + parts.map((part) => + part.type === 'integer' ? {part.value} : part.value, + ), + } + + return ( +
+

+ + {slots} + +

+ + + +
+ ) + } +}) diff --git a/test/components/FormattedNumberParts/index.test.ts b/test/components/FormattedNumberParts/index.test.ts new file mode 100644 index 0000000..e334329 --- /dev/null +++ b/test/components/FormattedNumberParts/index.test.ts @@ -0,0 +1,58 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { + createVIntlPlugin, + withAbnormalSpacesReplaced, +} from '../../utils/index.ts' +import { Counter } from './counter.tsx' + +describe('FormattedNumberParts', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk']) + const { plugin, controller, resetController } = vintl + + const { getByText, getByTestId } = render(Counter, { + global: { plugins: [plugin] }, + }) + + let counter: HTMLElement + const refreshCounter = () => (counter = getByTestId('counter')) + + const content = () => withAbnormalSpacesReplaced(counter.textContent!) + + beforeEach(async () => { + await fireEvent.click(getByText('Reset')) + refreshCounter() + }) + + afterEach(resetController) + + it('renders', async () => { + expect(content()).toBe('0') + + await fireEvent.click(getByText('+1')) + expect(content()).toBe('1') + + await fireEvent.click(getByText('+1000')) + expect(content()).toBe('1K') + + const integerParts = counter.querySelectorAll('b') + expect(integerParts).toHaveLength(1) + expect(integerParts[0].textContent).toBe('1') + }) + + it('changes locale', async () => { + await controller.changeLocale('uk') + + await fireEvent.click(getByText('+1')) + expect(content()).toBe('1') + + await fireEvent.click(getByText('+1000')) + expect(content()).toBe('1 тис.') + + const integerParts = counter.querySelectorAll('b') + expect(integerParts).toHaveLength(1) + expect(integerParts[0].textContent).toBe('1') + }) +}) diff --git a/test/components/FormattedPlural/index.test.ts b/test/components/FormattedPlural/index.test.ts new file mode 100644 index 0000000..8bab8f6 --- /dev/null +++ b/test/components/FormattedPlural/index.test.ts @@ -0,0 +1,97 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { createVIntlPlugin } from '../../utils/index.ts' +import { PluralDisplay } from './pluralDisplay.tsx' + +describe('FormattedPlural', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk']) + const { plugin, controller, resetController } = vintl + + const { getByText, getByTestId } = render(PluralDisplay, { + global: { plugins: [plugin] }, + }) + + let display: HTMLElement + const refreshDisplay = () => (display = getByTestId('plural-display')) + + afterEach(resetController) + + beforeEach(async () => { + await fireEvent.click(getByText('Reset')) + refreshDisplay() + }) + + const content = () => display.textContent! + + it('renders', async () => { + // 0 => other + // 1 => one + // 2 => other + + expect(content()).toMatchInlineSnapshot('"other with value 0"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"one with value 1"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"other with value 2"') + }) + + it('respects type', async () => { + // 0 => other + // 1 => one + // 2 => two + // 3 => few + + await fireEvent.click(getByText('Switch to ordinal')) + expect(content()).toMatchInlineSnapshot('"other with value 0"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"one with value 1"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"two with value 2"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"few with value 3"') + }) + + it('changes locale', async () => { + // 0 => many + // 1 => one + // 2 => few + + await controller.changeLocale('uk') + expect(content()).toMatchInlineSnapshot('"many with value 0"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"one with value 1"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"few with value 2"') + }) + + it('fallbacks correctly', async () => { + // 0 => many => other + // 1 => one => one + // 2 => few => other + + await controller.changeLocale('uk') + await fireEvent.click(getByText('Handle selection of slots')) + expect(content()).toMatchInlineSnapshot('"other with value 0"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"one with value 1"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"other with value 2"') + }) + + it('renders nothing if no slots handled', async () => { + // * => [nothing] + await fireEvent.click(getByText('Handle no slots')) + expect(content()).toMatchInlineSnapshot('""') + }) +}) diff --git a/test/components/FormattedPlural/pluralDisplay.tsx b/test/components/FormattedPlural/pluralDisplay.tsx new file mode 100644 index 0000000..0358b9d --- /dev/null +++ b/test/components/FormattedPlural/pluralDisplay.tsx @@ -0,0 +1,66 @@ +import { defineComponent, ref } from 'vue' +import { + FormattedPlural, + type FormattedPluralSlots, +} from '../../../dist/components' + +export const PluralDisplay = defineComponent(() => { + const count = ref(0) + const incrementByOne = () => (count.value += 1) + + const slotsHandling = ref<'full' | 'partial' | 'none'>('full') + const handleSelectionOfSlots = () => (slotsHandling.value = 'partial') + const handleNoSlots = () => (slotsHandling.value = 'none') + + const pluralType = ref('cardinal') + const switchToOrdinal = () => (pluralType.value = 'ordinal') + + const reset = () => { + count.value = 0 + slotsHandling.value = 'full' + pluralType.value = 'cardinal' + } + + return () => { + let slots: FormattedPluralSlots + + switch (slotsHandling.value) { + case 'full': + slots = { + zero: ({ value }) => `zero with value ${value}`, + one: ({ value }) => `one with value ${value}`, + two: ({ value }) => `two with value ${value}`, + few: ({ value }) => `few with value ${value}`, + many: ({ value }) => `many with value ${value}`, + other: ({ value }) => `other with value ${value}`, + } + break + case 'partial': + slots = { + one: ({ value }) => `one with value ${value}`, + other: ({ value }) => `other with value ${value}`, + } + break + case 'none': + slots = {} + break + } + + return ( + <> +

+ + {slots} + +

+ + + + + + + ) + } +}) diff --git a/test/components/FormattedRelativeTime/index.test.ts b/test/components/FormattedRelativeTime/index.test.ts new file mode 100644 index 0000000..0f432a7 --- /dev/null +++ b/test/components/FormattedRelativeTime/index.test.ts @@ -0,0 +1,67 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { + createVIntlPlugin, + withAbnormalSpacesReplaced, +} from '../../utils/index.ts' +import { RelativeTimeDisplay } from './relativeTimeDisplay.tsx' + +describe('FormattedRelativeTime', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk']) + const { plugin, controller, resetController } = vintl + + const { getByText, getByTestId } = render(RelativeTimeDisplay, { + global: { plugins: [plugin] }, + }) + + let display: HTMLElement + const refreshDisplay = () => (display = getByTestId('time-display')) + + beforeEach(async () => { + await fireEvent.click(getByText('Reset')) + refreshDisplay() + }) + + afterEach(resetController) + + const content = () => withAbnormalSpacesReplaced(display.textContent!) + + it('renders', async () => { + expect(content()).toMatchInlineSnapshot('"in 0 seconds"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"in 1 second"') + + await fireEvent.click(getByText('Use minutes')) + expect(content()).toMatchInlineSnapshot('"in 1 minute"') + }) + + it('changes locale', async () => { + await controller.changeLocale('uk') + expect(content()).toMatchInlineSnapshot('"через 0 секунд"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"через 1 секунду"') + + await fireEvent.click(getByText('Use minutes')) + expect(content()).toMatchInlineSnapshot('"через 1 хвилину"') + }) + + it('renders as a slot', async () => { + await fireEvent.click(getByText('Slots on')) + refreshDisplay() + + expect(content()).toMatchInlineSnapshot('"Relative time is: in 0 seconds"') + + await fireEvent.click(getByText('+1')) + expect(content()).toMatchInlineSnapshot('"Relative time is: in 1 second"') + + await fireEvent.click(getByText('Use minutes')) + expect(content()).toMatchInlineSnapshot('"Relative time is: in 1 minute"') + + const slot = getByTestId('time-slot') + expect(slot.textContent!).toMatchInlineSnapshot('"in 1 minute"') + }) +}) diff --git a/test/components/FormattedRelativeTime/relativeTimeDisplay.tsx b/test/components/FormattedRelativeTime/relativeTimeDisplay.tsx new file mode 100644 index 0000000..8fb2203 --- /dev/null +++ b/test/components/FormattedRelativeTime/relativeTimeDisplay.tsx @@ -0,0 +1,55 @@ +import { defineComponent, ref } from 'vue' +import { + FormattedRelativeTime, + type FormattedRelativeTimeSlots, +} from '../../../dist/components' + +export const RelativeTimeDisplay = defineComponent(() => { + const amount = ref(0) + const incrementByOne = () => (amount.value += 1) + + const unit = ref('seconds') + const switchToMinutes = () => (unit.value = 'minutes') + + const useSlots = ref(false) + const enableSlots = () => (useSlots.value = true) + + const reset = () => { + amount.value = 0 + unit.value = 'seconds' + useSlots.value = false + } + + return () => { + let display: JSX.Element + + if (useSlots.value) { + display = ( + + { + { + default: ({ formattedValue }) => ( + <> + {'Relative time is: '} + {formattedValue} + + ), + } satisfies FormattedRelativeTimeSlots + } + + ) + } else { + display = + } + + return ( + <> +

{display}

+ + + + + + ) + } +}) diff --git a/test/composables/useMessages/index.test.ts b/test/composables/useMessages/index.test.ts new file mode 100644 index 0000000..3ab910d --- /dev/null +++ b/test/composables/useMessages/index.test.ts @@ -0,0 +1,75 @@ +import { cleanup, fireEvent, render } from '@testing-library/vue' +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createVIntlPlugin } from '../../utils/index.ts' +import { messagesPayload, MessageDisplay } from './messageDisplay.tsx' + +describe('useMessages', () => { + afterAll(() => cleanup()) + + const vintl = createVIntlPlugin(['en-US', 'uk'], messagesPayload) + + const { plugin, controller, resetController } = vintl + + afterEach(resetController) + + const { getByText, getByTestId } = render(MessageDisplay, { + global: { plugins: [plugin] }, + }) + + // const display = getByTestId('message-display') + const messageContainer = getByTestId('message-container') + const warningContainer = getByTestId('warning-container') + + beforeEach(() => fireEvent.click(getByText('Reset'))) + + const messageHTML = () => messageContainer.innerHTML + const warningHTML = () => warningContainer.innerHTML + + it('renders', async () => { + expect(messageHTML()).toMatchInlineSnapshot( + '"Hello, Andrei!You don\'t have unread messages."', + ) + expect(warningHTML()).toMatchInlineSnapshot( + '"Warning! This is a warning."', + ) + + await fireEvent.click(getByText('Add unread')) + expect(messageHTML()).toMatchInlineSnapshot( + '"Hello, Andrei!You have 1 unread message."', + ) + + await fireEvent.click(getByText('Set intent to goodbye')) + expect(messageHTML()).toMatchInlineSnapshot( + '"Goodbye, Andrei!You have 1 unread message."', + ) + }) + + it('changes locale', async () => { + await controller.changeLocale('uk') + expect(messageHTML()).toMatchInlineSnapshot( + '"Привіт, Andrei!У вас немає непрочитаних повідомлень."', + ) + expect(warningHTML()).toMatchInlineSnapshot( + '"Попередження! Це попередження."', + ) + + // 1 = one + await fireEvent.click(getByText('Add unread')) + expect(messageHTML()).toMatchInlineSnapshot( + '"Привіт, Andrei!У вас є 1 непрочитане повідомлення."', + ) + + // 2 = few + await fireEvent.click(getByText('Add unread')) + expect(messageHTML()).toMatchInlineSnapshot( + '"Привіт, Andrei!У вас є 2 непрочитаних повідомлення."', + ) + + // safe to assume it will continue to handle number updates + + await fireEvent.click(getByText('Set intent to goodbye')) + expect(messageHTML()).toMatchInlineSnapshot( + '"До побачення, Andrei!У вас є 2 непрочитаних повідомлення."', + ) + }) +}) diff --git a/test/composables/useMessages/messageDisplay.tsx b/test/composables/useMessages/messageDisplay.tsx new file mode 100644 index 0000000..73d10d6 --- /dev/null +++ b/test/composables/useMessages/messageDisplay.tsx @@ -0,0 +1,92 @@ +import { computed, defineComponent, ref } from 'vue' +import { useMessage, useMessages } from '../../../dist/index' + +export const messagesPayload: Record> = { + 'en-US': { + greeting: 'Hello, {name}!', + farewell: 'Goodbye, {name}!', + inboxMessages: + "You {count, plural, =0 {don't have unread messages} one {have # unread message} other {have # unread messages}}.", + warnText: 'Warning! This is a warning.', + }, + uk: { + greeting: 'Привіт, {name}!', + farewell: 'До побачення, {name}!', + inboxMessages: + 'У вас {count, plural, =0 {немає непрочитаних повідомлень} one {є # непрочитане повідомлення} few {є # непрочитаних повідомлення} many {є # непрочитаних повідомлень} other {є непрочитаних повідомлень}}.', + warnText: 'Попередження! Це попередження.', + }, +} + +export const MessageDisplay = defineComponent(() => { + const incrementByOne = () => (unreadMessages.value += 1) + + const intent = ref<'hello' | 'goodbye'>('hello') + const setIntentToHello = () => (intent.value = 'hello') + const setIntentToGoodbye = () => (intent.value = 'goodbye') + + const reset = () => { + name.value = 'Andrei' + unreadMessages.value = 0 + intent.value = 'hello' + } + + const name = ref('Andrei') + + const unreadMessages = ref(0) + + const messages = useMessages({ + inboxMessages: { + id: 'inboxMessages', + defaultMessage: messagesPayload['en-US'].inboxMessages, + values: { count: unreadMessages }, + }, + warnText: { + id: 'warnText', + defaultMessage: messagesPayload['en-US'].warnText, + values: { + b(chunks) { + return {chunks} + }, + }, + }, + }) + + const helloMessage = useMessage({ + id: 'greeting', + defaultMessage: messagesPayload['en-US'].greeting, + values: { name }, + }) + + const goodbyeMessage = useMessage({ + id: 'farewell', + defaultMessage: messagesPayload['en-US'].farewell, + values: { name }, + }) + + const intentMessage = computed(() => + intent.value === 'hello' ? helloMessage.value : goodbyeMessage.value, + ) + + const Warning = defineComponent(() => () => messages.warnText) + + return () => { + return ( + <> +

+

+ {intentMessage.value} + {messages.inboxMessages} +
+
+ +
+

+ + + + + + ) + } +}) diff --git a/test/index.test.ts b/test/index.test.ts index 7710114..08bd865 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from 'vitest' import { type App, createApp, defineComponent } from 'vue' import { type PreferredLocalesSource, - useI18n, + useVIntl, type LocaleDescriptor, } from '../dist' import { type IntlController, createController } from '../dist/controller' @@ -35,7 +35,7 @@ let controller: IntlController const appComponent = defineComponent({ name: 'App', setup() { - const i18nResult = useI18n() + const i18nResult = useVIntl() return { i18nResult } }, }) diff --git a/test/utils/index.ts b/test/utils/index.ts new file mode 100644 index 0000000..6a9d3d8 --- /dev/null +++ b/test/utils/index.ts @@ -0,0 +1,42 @@ +import { type LocaleLoadEvent } from '../../dist/events' +import { createPlugin } from '../../dist/plugin' + +export function withAbnormalSpacesReplaced(value: string): string { + return value.replace(/[\u202F\u00A0]/g, ' ') +} + +export function createVIntlPlugin( + locales: string[], + loadLocale?: + | ((e: LocaleLoadEvent) => void | Promise) + | Record | undefined>, +) { + const plugin = createPlugin({ + controllerOpts: { + locales: locales.map((tag) => ({ tag })), + listen: { + localeload: + typeof loadLocale === 'function' || loadLocale == null + ? loadLocale + : (e) => { + e.addMessages(loadLocale[e.locale.tag] ?? {}) + }, + }, + }, + }) + + const controller = plugin.getOrCreateController() + + const initialState = { ...controller.$config } + + return { + plugin, + get controller() { + return controller + }, + async resetController() { + Object.assign(controller.$config, initialState) + await controller.waitUntilReady() + }, + } +} diff --git a/tsconfig.json b/tsconfig.json index bc40df9..34cfcea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,15 +2,19 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "module": "ESNext", - "moduleResolution": "Bundler", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "types": [], "outDir": "./dist", - "noEmit": true + "noEmit": true, + + "jsx": "react-jsx", + "jsxImportSource": "vue" }, - "include": ["./src/**/*.ts"], + "include": ["./src/**/*.ts", "./src/**/*.tsx"], "references": [ { "path": "./tsconfig.build.json" diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 0b65670..dd53719 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -5,13 +5,24 @@ "module": "ESNext", + "emitDeclarationOnly": true, + "allowImportingTsExtensions": true, + "moduleResolution": "bundler", "esModuleInterop": true, "types": [], - "lib": ["ES2022", "DOM"] + "lib": ["ES2022", "DOM"], + + "jsx": "react-jsx", + "jsxImportSource": "vue" }, - "include": ["./vitest.config.ts", "./test/*.test.ts", "./tsconfig.tests.json"] + "include": [ + "./vitest.config.ts", + "./test/**/*.ts", + "./test/**/*.tsx", + "./tsconfig.tests.json" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 9e3222f..f5daaed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,6 @@ /// import { defineConfig } from 'vitest/config' +import tsconfig from './tsconfig.tests.json' export default defineConfig({ test: { @@ -8,4 +9,7 @@ export default defineConfig({ }, environment: 'happy-dom', }, + esbuild: { + tsconfigRaw: tsconfig as any, + }, })