diff --git a/README.md b/README.md index 4af85e03..5fa5d604 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,10 @@ module.exports = { }, // Which field types are translated (default string, text, richtext, components and dynamiczones) // Either string or object with type and format - // Possible formats: plain, markdown, html (default plain) + // Possible formats: plain, markdown, html, jsonb (default plain) translatedFieldTypes: [ 'string', + { type: 'blocks', format: 'jsonb' }, { type: 'text', format: 'plain' }, { type: 'richtext', format: 'markdown' }, 'component', diff --git a/playground/config/plugins.js b/playground/config/plugins.js index ae500f3e..6ea4873c 100644 --- a/playground/config/plugins.js +++ b/playground/config/plugins.js @@ -6,6 +6,14 @@ module.exports = ({ env }) => ({ config: { provider: env('TRANSLATE_PROVIDER', 'deepl'), providerOptions: {}, + translatedFieldTypes: [ + 'string', + { type: 'blocks', format: 'jsonb' }, + { type: 'text', format: 'plain' }, + { type: 'richtext', format: 'markdown' }, + 'component', + 'dynamiczone', + ], regenerateUids: true, }, }, diff --git a/playground/cypress/e2e/batch-update.cy.js b/playground/cypress/e2e/batch-update.cy.js index 15ba72a9..e8bd0ab2 100644 --- a/playground/cypress/e2e/batch-update.cy.js +++ b/playground/cypress/e2e/batch-update.cy.js @@ -12,7 +12,7 @@ describe('batch update', () => { // Login and translate first category cy.login() cy.visit( - '/admin/content-manager/collectionType/api::category.category/create?plugins[i18n][locale]=de&plugins[i18n][relatedEntityId]=1' + '/admin/content-manager/collection-types/api::category.category/create?plugins[i18n][locale]=de&plugins[i18n][relatedEntityId]=1' ) cy.get('#name').type('translation') cy.get('button[type=submit]').focus() diff --git a/playground/src/api/blocks-article/content-types/blocks-article/schema.json b/playground/src/api/blocks-article/content-types/blocks-article/schema.json new file mode 100644 index 00000000..acfee8fb --- /dev/null +++ b/playground/src/api/blocks-article/content-types/blocks-article/schema.json @@ -0,0 +1,62 @@ +{ + "kind": "collectionType", + "collectionName": "blocks_articles", + "info": { + "singularName": "blocks-article", + "pluralName": "blocks-articles", + "displayName": "BlocksArticle", + "description": "" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": { + "i18n": { + "localized": true + } + }, + "attributes": { + "Title": { + "type": "string", + "pluginOptions": { + "i18n": { + "localized": true + }, + "translate": { + "translate": "translate" + } + } + }, + "Content": { + "type": "blocks", + "pluginOptions": { + "i18n": { + "localized": true + }, + "translate": { + "translate": "translate" + } + } + }, + "writer": { + "type": "relation", + "relation": "oneToOne", + "target": "api::writer.writer", + "pluginOptions": { + "translate": { + "translate": "translate" + } + } + }, + "category": { + "type": "relation", + "relation": "oneToOne", + "target": "api::category.category", + "pluginOptions": { + "translate": { + "translate": "translate" + } + } + } + } +} diff --git a/playground/src/api/blocks-article/controllers/blocks-article.js b/playground/src/api/blocks-article/controllers/blocks-article.js new file mode 100644 index 00000000..0b7065ca --- /dev/null +++ b/playground/src/api/blocks-article/controllers/blocks-article.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * blocks-article controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::blocks-article.blocks-article'); diff --git a/playground/src/api/blocks-article/routes/blocks-article.js b/playground/src/api/blocks-article/routes/blocks-article.js new file mode 100644 index 00000000..bd972f73 --- /dev/null +++ b/playground/src/api/blocks-article/routes/blocks-article.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * blocks-article router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::blocks-article.blocks-article'); diff --git a/playground/src/api/blocks-article/services/blocks-article.js b/playground/src/api/blocks-article/services/blocks-article.js new file mode 100644 index 00000000..7c9e081d --- /dev/null +++ b/playground/src/api/blocks-article/services/blocks-article.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * blocks-article service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::blocks-article.blocks-article'); diff --git a/plugin/README.md b/plugin/README.md index 94df4abe..f323eaf4 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -74,9 +74,10 @@ module.exports = { }, // Which field types are translated (default string, text, richtext, components and dynamiczones) // Either string or object with type and format - // Possible formats: plain, markdown, html (default plain) + // Possible formats: plain, markdown, html, jsonb (default plain) translatedFieldTypes: [ 'string', + { type: 'blocks', format: 'jsonb' }, { type: 'text', format: 'plain' }, { type: 'richtext', format: 'markdown' }, 'component', diff --git a/plugin/admin/src/utils/translatableFields.js b/plugin/admin/src/utils/translatableFields.js index 5971a786..6bd8ce22 100644 --- a/plugin/admin/src/utils/translatableFields.js +++ b/plugin/admin/src/utils/translatableFields.js @@ -6,6 +6,7 @@ const TRANSLATABLE_FIELDS = [ 'string', 'text', 'relation', + 'blocks', ] export default TRANSLATABLE_FIELDS diff --git a/plugin/package.json b/plugin/package.json index bb59b123..692b19bd 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -29,7 +29,9 @@ "dependencies": { "@strapi/helper-plugin": "^4.15.0", "axios": "^1.7.4", + "blocks-html-renderer": "^1.0.5", "bottleneck": "^2.19.5", + "cache-manager": "^6.1.0", "jsdom": "^25.0.0", "showdown": "^2.1.0" }, diff --git a/plugin/server/config/index.js b/plugin/server/config/index.js index 4d7907a1..5390292e 100644 --- a/plugin/server/config/index.js +++ b/plugin/server/config/index.js @@ -45,7 +45,7 @@ module.exports = { } if ( field.format && - !['plain', 'markdown', 'html'].includes(field.format) + !['plain', 'markdown', 'html', 'jsonb'].includes(field.format) ) { throw new Error( `unhandled format ${field.format} for translated field ${field.type}` diff --git a/plugin/server/services/__tests__/block.json b/plugin/server/services/__tests__/block.json new file mode 100644 index 00000000..be59b739 --- /dev/null +++ b/plugin/server/services/__tests__/block.json @@ -0,0 +1,233 @@ +[ + [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "text": "This is a new English Text." + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "text": "The " + }, + { + "type": "text", + "text": "highlighted and ", + "bold": true, + "italic": true + }, + { + "text": "not highlighted regions, should be persisted. ", + "type": "text" + }, + { + "type": "link", + "url": "https://example.com/", + "children": [ + { + "type": "text", + "text": "Links " + } + ] + }, + { + "type": "text", + "text": "should work as well. " + }, + { + "text": "Inline code", + "type": "text", + "code": true + }, + { + "text": " should probably not be translated.", + "type": "text" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "text": "Images should be preserved correctly:" + } + ] + }, + { + "type": "image", + "image": { + "name": "default-image", + "alternativeText": "default-image", + "url": "http://localhost:1337/uploads/default_image_42c3f849b9.png", + "caption": "default-image", + "width": 1208, + "height": 715, + "formats": { + "thumbnail": { + "name": "thumbnail_default-image", + "hash": "thumbnail_default_image_42c3f849b9", + "ext": ".png", + "mime": "image/png", + "path": null, + "width": 245, + "height": 145, + "size": 23.28, + "url": "/uploads/thumbnail_default_image_42c3f849b9.png" + }, + "medium": { + "name": "medium_default-image", + "hash": "medium_default_image_42c3f849b9", + "ext": ".png", + "mime": "image/png", + "path": null, + "width": 750, + "height": 444, + "size": 187.83, + "url": "/uploads/medium_default_image_42c3f849b9.png" + }, + "small": { + "name": "small_default-image", + "hash": "small_default_image_42c3f849b9", + "ext": ".png", + "mime": "image/png", + "path": null, + "width": 500, + "height": 296, + "size": 77.77, + "url": "/uploads/small_default_image_42c3f849b9.png" + }, + "large": { + "name": "large_default-image", + "hash": "large_default_image_42c3f849b9", + "ext": ".png", + "mime": "image/png", + "path": null, + "width": 1000, + "height": 592, + "size": 343.7, + "url": "/uploads/large_default_image_42c3f849b9.png" + } + }, + "hash": "default_image_42c3f849b9", + "ext": ".png", + "mime": "image/png", + "size": 81.61, + "previewUrl": null, + "provider": "local", + "provider_metadata": null, + "createdAt": "2023-03-20T13:39:39.483Z", + "updatedAt": "2023-03-20T13:39:39.483Z" + }, + "children": [ + { + "type": "text", + "text": "" + } + ] + }, + { + "type": "code", + "children": [ + { + "type": "text", + "text": "Code blocks should also probably not be translated\nint variable = function(example);" + } + ] + }, + { + "type": "heading", + "children": [ + { + "type": "text", + "text": "Headings should be preserved." + } + ], + "level": 3 + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "text": "" + } + ] + }, + { + "type": "quote", + "children": [ + { + "type": "text", + "text": "Quotes are probably fine to translate?" + } + ] + }, + { + "type": "list", + "format": "ordered", + "children": [ + { + "type": "list-item", + "children": [ + { + "type": "text", + "text": "Lists should be" + } + ] + }, + { + "type": "list-item", + "children": [ + { + "type": "text", + "text": "preserved" + } + ] + }, + { + "type": "list-item", + "children": [ + { + "type": "text", + "text": "in their" + } + ] + }, + { + "type": "list-item", + "children": [ + { + "type": "text", + "text": "order" + } + ] + }, + { + "type": "list-item", + "children": [ + { + "type": "text", + "text": "but translated" + } + ] + }, + { + "type": "list-item", + "children": [ + { + "type": "text", + "text": "on their own" + } + ] + } + ] + } + ] +] \ No newline at end of file diff --git a/plugin/server/services/__tests__/format-service.test.js b/plugin/server/services/__tests__/format-service.test.js index 47f8d990..1938aaca 100644 --- a/plugin/server/services/__tests__/format-service.test.js +++ b/plugin/server/services/__tests__/format-service.test.js @@ -1,5 +1,7 @@ 'use strict' +const block = require('./block.json') + const markdown = `# Turndown Demo This demonstrates [turndown]() \\- an HTML to Markdown converter in JavaScript. @@ -106,4 +108,11 @@ describe('format', () => { formatService.htmlToMarkdown(formatService.markdownToHtml(markdown)) ).toEqual(markdown) }) + test('block to html and back', async () => { + const formatService = strapi.service('plugin::translate.format') + const html = await formatService.blockToHtml(block) + await expect( + formatService.htmlToBlock(html) + ).resolves.toEqual(block) + }) }) diff --git a/plugin/server/services/format.js b/plugin/server/services/format.js index aa9d055f..09b094b2 100644 --- a/plugin/server/services/format.js +++ b/plugin/server/services/format.js @@ -2,6 +2,10 @@ const showdown = require('showdown') const jsdom = require('jsdom') +const cacheManager = require('cache-manager') +const renderBlock = require('blocks-html-renderer').renderBlock +const { TRANSLATE_BLOCKS_IMAGE_CACHE_TTL } = require('../utils/constants') + const dom = new jsdom.JSDOM() const showdownConverter = new showdown.Converter({ @@ -9,6 +13,10 @@ const showdownConverter = new showdown.Converter({ strikethrough: true, }) +const blocksImageCache = cacheManager.createCache({ + ttl: TRANSLATE_BLOCKS_IMAGE_CACHE_TTL +}) + function markdownToHtml(singleText) { return showdownConverter.makeHtml(singleText) } @@ -20,6 +28,167 @@ function htmlToMarkdown(singleText) { .trim() } +/** + * + * @param {Array} blocks + */ +async function cacheImages(blocks) { + for (const block of blocks.flat(2)) { + if (block.type === 'image') { + await blocksImageCache.set(block.image.url, block.image) + } + } +} + +/** + * + * @param {ChildNode} childNode + * @returns {Array} + */ +function collectFormattings(childNode) { + if (childNode.nodeName === '#text' || childNode.childNodes.length === 0) { + return [] + } + if (childNode.childNodes.length > 1) { + throw new Error('collectFormattings expects an element with a single child') + } + const formattings = collectFormattings(childNode.childNodes[0]) + if (childNode.tagName === 'STRONG') { + formattings.push('bold') + } + if (childNode.tagName === 'EM') { + formattings.push('italic') + } + if (childNode.tagName === 'U') { + formattings.push('underline') + } + if (childNode.tagName === 'S') { + formattings.push('strikethrough') + } + if (childNode.tagName === 'CODE') { + formattings.push('code') + } + return formattings +} + +/** + * + * @param {HTMLElement} element + * @returns + */ +function convertInlineElementToBlocks(element) { + const elements = [] + for (const child of element.childNodes) { + if (child.tagName === 'A') { + elements.push({ + type: 'link', + url: child.href, + children: convertInlineElementToBlocks(child), + }) + continue + } + try { + const formattings = collectFormattings(child) + const element = { + type: 'text', + text: child.textContent, + } + for (const formatting of formattings) { + element[formatting] = true + } + elements.push(element) + } catch (error) { + strapi.log.error(`Error while converting inline element ${element.outerHTML} to blocks, falling back to no formatting`, error) + elements.push({ + type: 'text', + text: child.textContent, + }) + } + } + if (elements.length === 0) { + elements.push({ + type: 'text', + text: element.textContent, + }) + } + return elements +} + + +async function convertHtmlToBlock(html) { + const root = dom.window.document.createElement('div') + root.innerHTML = html + + const blocks = [] + + for (const child of root.children) { + if (child.tagName === 'P') { + blocks.push({ + type: 'paragraph', + children: convertInlineElementToBlocks(child), + }) + } + if (/^H[1-6]$/.test(child.tagName)) { + const level = parseInt(child.tagName[1], 10) + blocks.push({ + type: 'heading', + level, + children: convertInlineElementToBlocks(child), + }) + } + if (/^[UO]L$/.test(child.tagName)) { + const listItems = Array.from(child.children).map(li => ({ + type: 'list-item', + children: convertInlineElementToBlocks(li), + })) + blocks.push({ + type: 'list', + format: child.tagName === 'UL' ? 'unordered' : 'ordered', + children: listItems, + }) + } + if (child.tagName === 'BLOCKQUOTE') { + blocks.push({ + type: 'quote', + children: convertInlineElementToBlocks(child), + }) + } + if (child.tagName === 'PRE') { + // pre also has a code child + const code = child.querySelector('code') + blocks.push({ + type: 'code', + children: [ + { + type: 'text', + text: code.textContent, + } + ] + }) + } + if (child.tagName === "IMG") { + const cachedImage = await blocksImageCache.get(child.src) + const image = cachedImage != null ? cachedImage : { + url: child.src, + alt: child.alt, + } + blocks.push({ + type: 'image', + image, + children: convertInlineElementToBlocks(child), + }) + } + if (child.tagName === "A") { + blocks.push({ + type: 'link', + url: child.href, + children: convertInlineElementToBlocks(child), + }) + } + } + return blocks +} + module.exports = () => ({ markdownToHtml(text) { if (Array.isArray(text)) { @@ -33,4 +202,22 @@ module.exports = () => ({ } return htmlToMarkdown(text) }, + async blockToHtml(block) { + if (!Array.isArray(block)) { + throw new Error('blockToHtml expects an array of blocks or a single block. Got ' + typeof block) + } + await cacheImages(block) + if (block.length > 0 ) { + if (!block[0].type) { + return block.map(renderBlock) + } + return renderBlock(block) + } + }, + async htmlToBlock(html) { + if (Array.isArray(html)) { + return Promise.all(html.map(convertHtmlToBlock)) + } + return convertHtmlToBlock(html) + }, }) diff --git a/plugin/server/utils/constants.js b/plugin/server/utils/constants.js index b4bb6da5..f1d407c8 100644 --- a/plugin/server/utils/constants.js +++ b/plugin/server/utils/constants.js @@ -5,4 +5,5 @@ module.exports = { TRANSLATE_PRIORITY_BATCH_TRANSLATION: 6, TRANSLATE_PRIORITY_DIRECT_TRANSLATION: 3, TRANSLATE_PRIORITY_DEFAULT: 5, + TRANSLATE_BLOCKS_IMAGE_CACHE_TTL: 60000, } diff --git a/providers/deepl/lib/index.js b/providers/deepl/lib/index.js index 093a1f80..ad403bac 100644 --- a/providers/deepl/lib/index.js +++ b/providers/deepl/lib/index.js @@ -47,7 +47,7 @@ module.exports = { return { /** * @param {{ - * text:string|string[], + * text:string|string[]|any[], * sourceLocale: string, * targetLocale: string, * priority: number, @@ -68,12 +68,15 @@ module.exports = { const tagHandling = format === 'plain' ? undefined : 'html' - let textArray = Array.isArray(text) ? text : [text] - - if (format === 'markdown') { - textArray = formatService.markdownToHtml(textArray) + let input = text + if (format === 'jsonb') { + input = await formatService.blockToHtml(input) + } else if (format === 'markdown') { + input = formatService.markdownToHtml(input) } + let textArray = Array.isArray(input) ? input : [input] + const { chunks, reduceFunction } = chunksService.split(textArray, { maxLength: DEEPL_API_MAX_TEXTS, maxByteSize: DEEPL_API_ROUGH_MAX_REQUEST_SIZE, @@ -98,7 +101,10 @@ module.exports = { }) ) ) - + + if (format === 'jsonb') { + return formatService.htmlToBlock(result) + } if (format === 'markdown') { return formatService.htmlToMarkdown(result) } diff --git a/providers/libretranslate/lib/index.js b/providers/libretranslate/lib/index.js index ff22bf8e..83640602 100644 --- a/providers/libretranslate/lib/index.js +++ b/providers/libretranslate/lib/index.js @@ -49,7 +49,7 @@ module.exports = { return { /** * @param {{ - * text:string|string[], + * text:string|string[]|any[], * sourceLocale: string, * targetLocale: string, * priority: number, @@ -73,21 +73,20 @@ module.exports = { const chunksService = getService('chunks') const formatService = getService('format') - let textArray = Array.isArray(text) ? text : [text] - - if (format === 'markdown') { - textArray = formatService.markdownToHtml(textArray) + let input = text + if (format === 'jsonb') { + input = await formatService.blockToHtml(input) + } else if (format === 'markdown') { + input = formatService.markdownToHtml(input) } + const textArray = Array.isArray(input) ? input : [input] + const { chunks, reduceFunction } = chunksService.split(textArray, { maxLength: maxTexts === -1 ? Number.MAX_VALUE : maxTexts, maxByteSize: maxCharacters === -1 ? Number.MAX_VALUE : maxCharacters, }) - if (format === 'markdown') { - textArray = formatService.markdownToHtml(textArray) - } - const result = reduceFunction( await Promise.all( chunks.map(async (texts) => { @@ -108,6 +107,9 @@ module.exports = { ) ) + if (format === 'jsonb') { + return formatService.htmlToBlock(result) + } if (format === 'markdown') { return formatService.htmlToMarkdown(result) } diff --git a/yarn.lock b/yarn.lock index 044e8c90..1a0b563b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2165,6 +2165,13 @@ resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== +"@keyv/serialize@*": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.0.1.tgz#8dae240d5fe11c589e38b73a2db238dcf26a33cf" + integrity sha512-kKXeynfORDGPUEEl2PvTExM2zs+IldC6ZD8jPcfvI351MDNtfMlw9V9s4XZXuJNDK2qR5gbEKxRyoYx3quHUVQ== + dependencies: + buffer "^6.0.3" + "@koa/cors@5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-5.0.0.tgz#0029b5f057fa0d0ae0e37dd2c89ece315a0daffd" @@ -5369,6 +5376,11 @@ blob-util@^2.0.2: resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== +blocks-html-renderer@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/blocks-html-renderer/-/blocks-html-renderer-1.0.5.tgz#c4882b9a4cd6f8a80f31d7960f9ff11dacb4f6cc" + integrity sha512-KT3GqV+xsYIgKZX2vJ7/1zEu1eiidGvzZH+Sk4eGBZ4Be/x+pXo/LJRc5a21IzbCsz68wfQd9+GqUCTM3s0NRg== + blork@^9.3.0: version "9.3.0" resolved "https://registry.yarnpkg.com/blork/-/blork-9.3.0.tgz#6c0b4fbb6b754998ae5460c26463d95c635e4a35" @@ -5550,6 +5562,14 @@ buffer@^5.1.0, buffer@^5.5.0, buffer@^5.6.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + buildmail@3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/buildmail/-/buildmail-3.10.0.tgz#c6826d716e7945bb6f6b1434b53985e029a03159" @@ -5651,6 +5671,13 @@ cache-content-type@^1.0.0: mime-types "^2.1.18" ylru "^1.2.0" +cache-manager@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/cache-manager/-/cache-manager-6.1.0.tgz#e1d60430b358decc8b6df2ce6cdc3013303740e4" + integrity sha512-Z0gN4aTrCPNxWnXJqdTM+fAFbjYV1al9PNb3bShtw8CeNuaDloYm184f4wPNodPzTBznT3F2sqzTpwlGcL2Hjg== + dependencies: + keyv "^5.0.3" + cacheable-lookup@^5.0.3: version "5.0.4" resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" @@ -9838,7 +9865,7 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -11468,6 +11495,13 @@ keyv@*, keyv@^4.0.0: dependencies: json-buffer "3.0.1" +keyv@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.0.3.tgz#f0a5a3b3bf41ee4a15a1c481140c0f1e26e6af3f" + integrity sha512-WmefGWaWkWiWDkIasfHxpWmM1lych/LPtRmNj8jnIQVGLsAgFw73Vg9utZ7ss97/JwRlERABb/fSejTPY4hlZQ== + dependencies: + "@keyv/serialize" "*" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"