diff --git a/nodemon.json b/nodemon.json index 70b8ccc..8c93bb1 100644 --- a/nodemon.json +++ b/nodemon.json @@ -8,5 +8,5 @@ "watch": [ "**/*" ], - "ext": "js,twig" + "ext": "ts,js,twig" } diff --git a/package.json b/package.json index 517ea2f..f8e259c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@babel/polyfill": "^7.12.1", "@babel/preset-env": "^7.16.11", "@codexteam/misprints": "^1.0.0", + "@codexteam/shortcuts": "^1.2.0", "@editorjs/checklist": "^1.3.0", "@editorjs/code": "^2.7.0", "@editorjs/delimiter": "^1.2.0", @@ -83,6 +84,7 @@ "@types/sinon": "^10.0.2", "@types/twig": "^1.12.6", "autoprefixer": "^10.4.2", + "axios": "^0.27.2", "babel": "^6.23.0", "babel-eslint": "^10.0.1", "babel-loader": "^8.2.3", diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts new file mode 100644 index 0000000..12f50dc --- /dev/null +++ b/src/backend/controllers/search.ts @@ -0,0 +1,302 @@ +import PageData from '../models/page.js'; +import Pages from '../controllers/pages.js'; +import urlify from '../utils/urlify.js'; +import Page from '../models/page.js'; + +let globalWords: { [key: string]: {[key: string]: number} } = Object.create(null); +let globalPages: PageData[] = []; + +class Search { + /** + * Initialize search + */ + public async init() { + if (globalWords && Object.keys(globalWords).length) { + return Promise.resolve(); + } + + await this.syncDB(); + } + + /** + * Load all pages from DB and update globalWords + * Use this method when any page was updated + */ + public async syncDB() { + globalWords = Object.create(null); + globalPages = await this.getPages(); + + /** + * Process all pages + */ + for await (const page of globalPages) { + /** + * Read content blocks from page + */ + for await (const block of page.body.blocks) { + const blockRatio = this.getBlockRatio(block); + const blockContent = this.getCleanTextFromBlock(block); + const blockWords: string[] = this.splitTextToWords(blockContent); + + /** + * Process list of words in a block + */ + for await (const word of blockWords) { + if (!globalWords[word]) { + globalWords[word] = Object.create(null); + } + + if (page._id) { + if (!globalWords[word][page._id]) { + globalWords[word][page._id] = 0; + } + + /** + * Add page id to the list of pages with this word + */ + globalWords[word][page._id] += blockRatio; + } + } + } + } + + console.log('Done'); + } + + /** + * Search for pages by given query + * @param searchString + */ + public async query(searchString: string) { + await this.init(); + + const searchWords = this.splitTextToWords(searchString); + + const goodPages = (await this.getPagesByWords(searchWords)) + .slice(0, 10); + + const returnPages: {[key: string]: string|number, ratio: number}[] = []; + + goodPages.forEach(({ pageId, ratio }) => { + const page = globalPages.filter(page => page._id === pageId).pop(); + + if (!page) { + return; + } + + let section = ''; + + page.body.blocks.forEach((block: any) => { + let koef = 1; + + let blockContent = this.getCleanTextFromBlock(block); + + let shortBody = blockContent; + + if (block.type === 'header') { + section = blockContent; + } + + searchWords.forEach(word => { + if (blockContent.toLowerCase().indexOf(word) !== -1) { + koef *= 10; + } + }) + + shortBody = this.highlightSubstring(shortBody, searchWords); + + if (koef > 0) { + returnPages.push({ + ...page, + shortBody, + anchor: urlify(section), + section, + ratio: ratio * koef, + }) + } + }); + }); + + return { + suggestions: ['description', 'about', 'contact'], + pages: returnPages + .sort((a, b) => b.ratio - a.ratio) + .slice(0, 15) + } + } + + /** + * + * @private + */ + private async getPages(): Promise { + return await Pages.getAll(); + } + + /** + * Return list of pages with a given words + * @param words + * @private + */ + private async getPagesByWords(words: string[]) { + const pagesList: {[key: string]: number} = {}; + + /** + * Get list of words starting with a words from the search query + */ + const validWords = Object.keys(globalWords) + .filter(word => { + return !!words.filter(searchWord => word.indexOf(searchWord) !== -1).length + }); + + /** + * For each word get list of pages with this word + */ + validWords.forEach(word => { + Object.keys(globalWords[word]) + .forEach(pageId => { + if (!pagesList[pageId]) { + pagesList[pageId] = 0; + } + + pagesList[pageId] += globalWords[word][pageId] + }) + }) + + /** + * Sort pages by frequency of given words + */ + const sortedPagesList = Object.keys(pagesList) + .map(pageId => { + return { + pageId, + ratio: pagesList[pageId] + } + }) + .sort((a, b) => b.ratio - a.ratio); + + return sortedPagesList; + } + + /** + * Get block's ratio. It is used to calculate the weight of the words in the block + * @param block + * @private + */ + private getBlockRatio(block: any) { + switch (block.type) { + case 'header': + if (block.data.level === 1) { + return 16; + } else { + return 2; + } + + case 'paragraph': + return 1.1; + + case 'list': + return 1; + + default: + return 0; + } + } + + /** + * Return clear text content from block without HTML tags and special characters + * @param block + * @private + */ + private getCleanTextFromBlock(block: any): string { + let blockContent = ''; + + switch (block.type) { + case 'header': + blockContent = block.data.text; + break; + + case 'paragraph': + blockContent = block.data.text + break; + + case 'list': + blockContent = block.data.items.join(' '); + break; + + default: + return blockContent; + } + + blockContent = this.removeHTMLTags(blockContent); + blockContent = this.removeHTMLSpecialCharacters(blockContent); + + return blockContent; + } + + /** + * Remove HTML tags from string. Only content inside tags will be left + * @param text + * @private + */ + private removeHTMLTags(text: string) { + return text.replace(/<[^>]*>?/gm, ''); + } + + /** + * Remove special characters from text. For example:   & " < > + * @param text + * @private + */ + private removeHTMLSpecialCharacters(text: string) { + return text.replace(/&[^;]*;?/gm, ''); + } + + /** + * Split text to words + * @param text + * @private + */ + private splitTextToWords(text: string): string[] { + return text + // lowercase all words + .toLowerCase() + + // remove punctuation + .replace(/[.,;:]/gi, '') + + // left only letters (+cyrillic) and numbers + .replace(/[^a-zа-я0-9]/gi, ' ') + + // remove multiple spaces + .replace(/\s+/g, ' ') + + // remove spaces at the beginning and at the end + .trim() + + // split to words by spaces + .split(' ') + + // ignore words shorter than 3 chars + .filter(word => word.length >= 3); + } + + /** + * Highlight substring in string with a span wrapper + */ + private highlightSubstring(text: string, words: string|string[]) { + if (typeof words === 'string') { + words = [words]; + } + + const wordRegExp = new RegExp(words.join('|'), "ig"); + const CLASS_STYLE = 'search-word'; + + return text.replace(wordRegExp, `$&`); + } +} + +/** + * Export initialized instance + */ +export default new Search(); diff --git a/src/backend/routes/api/index.ts b/src/backend/routes/api/index.ts index 7cb2328..67cd94e 100644 --- a/src/backend/routes/api/index.ts +++ b/src/backend/routes/api/index.ts @@ -1,12 +1,15 @@ import express from 'express'; + import pagesAPI from './pages.js'; import transportAPI from './transport.js'; import linksAPI from './links.js'; +import searchAPI from './search.js'; const router = express.Router(); router.use('/', pagesAPI); router.use('/', transportAPI); router.use('/', linksAPI); +router.use('/', searchAPI); export default router; diff --git a/src/backend/routes/api/pages.ts b/src/backend/routes/api/pages.ts index f4b1673..0b8b944 100644 --- a/src/backend/routes/api/pages.ts +++ b/src/backend/routes/api/pages.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'; import multerFunc from 'multer'; import Pages from '../../controllers/pages.js'; import PagesOrder from '../../controllers/pagesOrder.js'; +import Search from '../../controllers/search.js'; const router = express.Router(); const multer = multerFunc(); @@ -70,6 +71,9 @@ router.put('/page', multer.none(), async (req: Request, res: Response) => { /** push to the orders array */ await PagesOrder.push(parent, page._id); + /** Update search index */ + await Search.syncDB(); + res.json({ success: true, result: page, @@ -127,6 +131,10 @@ router.post('/page/:id', multer.none(), async (req: Request, res: Response) => { parent, uri, }); + + /** Update search index */ + await Search.syncDB(); + res.json({ success: true, result: page, @@ -206,6 +214,9 @@ router.delete('/page/:id', async (req: Request, res: Response) => { parentPageOrder.remove(req.params.id); await parentPageOrder.save(); + /** Update search index */ + await Search.syncDB(); + res.json({ success: true, result: pageToRedirect, diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts new file mode 100644 index 0000000..85700b2 --- /dev/null +++ b/src/backend/routes/api/search.ts @@ -0,0 +1,66 @@ +import express, { Request, Response } from 'express'; +import Search from '../../controllers/search.js'; + +const router = express.Router(); + +/** + * GET /search/:searchString + * + * Search given words in all documents + */ +router.get('/search', async (req: Request, res: Response) => { + try { + /** + * Get search string + */ + const searchString = req.query.text as string; + + /** + * Get search results + */ + const searchResponse = await Search.query(searchString); + + /** + * Compose response + */ + const compactedPages = searchResponse.pages.map(page => { + return { + /** Page id */ + _id: page._id, + + /** Page title */ + title: page.title, + + /** Page uri */ + uri: page.uri, + + /** Section heading name for the found fragment */ + section: page.section, + + /** Section's anchor */ + anchor: page.anchor, + + /** Page fragment with searched items */ + shortBody: page.shortBody, + }; + }); + + res.json({ + success: true, + result: { + /** Found pages */ + pages: compactedPages, + + /** Typing suggestions */ + suggestions: searchResponse.suggestions, + }, + }); + } catch (err) { + res.status(400).json({ + success: false, + error: (err as Error).message, + }); + } +}); + +export default router; diff --git a/src/backend/views/layout.twig b/src/backend/views/layout.twig index c35b0e8..429c4f9 100644 --- a/src/backend/views/layout.twig +++ b/src/backend/views/layout.twig @@ -15,7 +15,7 @@ hawkClientToken:"{{ config.hawkClientToken }}", }; - + {% include "components/header.twig" with res.locals.isAuthorized %}
diff --git a/src/backend/views/pages/page.twig b/src/backend/views/pages/page.twig index 796b659..ba1df3e 100644 --- a/src/backend/views/pages/page.twig +++ b/src/backend/views/pages/page.twig @@ -26,7 +26,7 @@ {% include 'components/button.twig' with {label: 'Edit', icon: 'pencil', size: 'small', url: '/page/edit/' ~ page._id, class: 'page__header-button'} %} {% endif %} -

+

{{ page.title }}

{% if (config.carbon and config.carbon.placement and config.carbon.serve) %} diff --git a/src/frontend/js/app.js b/src/frontend/js/app.js index 1bd665e..132d183 100644 --- a/src/frontend/js/app.js +++ b/src/frontend/js/app.js @@ -17,6 +17,7 @@ import Writing from './modules/writing'; import Page from './modules/page'; import Extensions from './modules/extensions'; import Sidebar from './modules/sidebar'; +import Search from './modules/search'; import HawkCatcher from '@hawk.so/javascript'; /** @@ -31,6 +32,8 @@ class Docs { this.page = new Page(); this.extensions = new Extensions(); this.sidebar = new Sidebar(); + this.search = new Search(); + if (window.config.hawkClientToken) { this.hawk = new HawkCatcher(window.config.hawkClientToken); } diff --git a/src/frontend/js/modules/page.js b/src/frontend/js/modules/page.js index 104928e..4b6a185 100644 --- a/src/frontend/js/modules/page.js +++ b/src/frontend/js/modules/page.js @@ -17,6 +17,7 @@ export default class Page { init() { this.codeStyler = this.createCodeStyling(); this.tableOfContent = this.createTableOfContent(); + this.search = this.createSearch(); } /** diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js new file mode 100644 index 0000000..d692967 --- /dev/null +++ b/src/frontend/js/modules/search.js @@ -0,0 +1,238 @@ +import { debounce } from '../utils/decorators'; +import Shortcut from '@codexteam/shortcuts'; +import axios from 'axios'; + +export default class Search { + constructor() { + this.nodes = { + overlay: null, + searchWrapper: null, + searchInput: null, + searchResultWrapper: null + }; + + this.isVisible = false; + + this.PLACEHOLDER = 'Search docs'; + + this.TOGGLER_SHORTCUT = 'CMD+SHIFT+F'; + this.shortcut = null; + + this.DEBOUNCE_TIME = 300; + this.debouncedSearch = null; + + this.MIN_SEARCH_LENGTH = 1; + + this.CSS = { + overlay: 'search-overlay', + overlayVisible: 'search-overlay--visible', + searchWrapper: 'search-wrapper', + searchInput: 'search-input', + searchResultWrapper: 'search-result-wrapper', + + searchResultSuggestions: 'search-result-suggestions', + searchResultSuggestionItem: 'search-result-suggestions-item', + + searchResultItem: 'search-result-item', + searchResultItemTitle: 'search-result-item__title', + searchResultItemSection: 'search-result-item__section', + searchResultItemDescription: 'search-result-item__description', + + blurred: 'blurred', + noscroll: 'noscroll' + }; + } + + init(settings = {}, moduleEl) { + console.log('search init'); + + this.createSearchOverlay(); + this.createDebouncedSearch(); + this.enableShortcutListening(); + + /** + * Only for development needs: + * + * - force open overlay + * - type a search string + * - fire search + */ + // const testString = 'codex descri'; + // this.toggleSearchOverlay(true); + // this.nodes.searchInput.value = testString; + // this.debouncedSearch(testString); + } + + createSearchOverlay() { + this.nodes.overlay = document.createElement('div'); + this.nodes.overlay.classList.add(this.CSS.overlay); + this.nodes.overlay.addEventListener('click', this.searchOverlayClickProcessor.bind(this)); + + this.nodes.searchWrapper = document.createElement('div'); + this.nodes.searchWrapper.classList.add(this.CSS.searchWrapper); + + this.nodes.searchInput = document.createElement('input'); + this.nodes.searchInput.classList.add(this.CSS.searchInput); + this.nodes.searchInput.setAttribute('type', 'search'); + this.nodes.searchInput.setAttribute('placeholder', this.PLACEHOLDER); + this.nodes.searchInput.setAttribute('autocomplete', 'off'); + this.nodes.searchInput.addEventListener('input', this.searchInputOnchangeProcessor.bind(this)); + this.nodes.searchWrapper.appendChild(this.nodes.searchInput); + + this.nodes.searchResultWrapper = document.createElement('div'); + this.nodes.searchResultWrapper.classList.add(this.CSS.searchResultWrapper); + this.nodes.searchWrapper.appendChild(this.nodes.searchResultWrapper); + + this.nodes.overlay.appendChild(this.nodes.searchWrapper); + document.body.appendChild(this.nodes.overlay); + } + + searchOverlayClickProcessor(event) { + if (event.target !== this.nodes.overlay) { + return; + } + + this.toggleSearchOverlay(false); + } + + searchInputOnchangeProcessor(event) { + const text = event.target.value; + + if (text.length < this.MIN_SEARCH_LENGTH) { + this.clearSearchResults(); + return; + } + + this.debouncedSearch(text); + } + + enableShortcutListening() { + this.shortcut = new Shortcut({ + name : this.TOGGLER_SHORTCUT, + on : document.body, + callback: (event) => { + this.toggleSearchOverlay(); + } + }); + } + + toggleSearchOverlay(force) { + this.isVisible = force || !this.isVisible; + + this.nodes.overlay.classList.toggle(this.CSS.overlayVisible, this.isVisible); + document.body.classList.toggle(this.CSS.noscroll, this.isVisible); + + // blur everything except search overlay + try { + document.getElementsByClassName('docs-header')[0].classList.toggle(this.CSS.blurred, this.isVisible); + document.getElementsByClassName('docs')[0].classList.toggle(this.CSS.blurred, this.isVisible); + } catch (e) {} + + this.nodes.searchInput.focus(); + this.nodes.searchInput.select(); + } + + createDebouncedSearch() { + this.debouncedSearch = debounce(this.getSearchResults, this.DEBOUNCE_TIME); + } + + getSearchResults(text) { + + axios.get('/api/search', { + params: { + text + } + }) + .then(this.showSearchResult.bind(this)); + } + + clearSearchResults() { + this.nodes.searchResultWrapper.innerHTML = ''; + } + + showSearchResult({ data }) { + this.clearSearchResults(); + + // if (data.result.suggestions.length) { + // this.showSuggestedWordCompletion(data.result.suggestions[0]); + // } + + // const suggestionsWrapper = this.generateSearchSuggestions(data.result.suggestions); + // + // this.nodes.searchResultWrapper.appendChild(suggestionsWrapper); + + data.result.pages.forEach(page => { + const url = `/${page.uri}` + (page.section ? `#${page.anchor}` : ''); + + const result = document.createElement('a'); + result.classList.add(this.CSS.searchResultItem); + result.setAttribute('href', url); + result.addEventListener('click', this.toggleSearchOverlay.bind(this, false)); + + const title = document.createElement('div'); + title.classList.add(this.CSS.searchResultItemTitle); + title.innerHTML = page.title; + result.appendChild(title); + + if (page.section && page.section !== page.title) { + const section = document.createElement('span'); + section.classList.add(this.CSS.searchResultItemSection); + section.innerHTML = `${page.section}`; + title.appendChild(section); + } + + const description = document.createElement('div'); + description.classList.add(this.CSS.searchResultItemDescription); + description.innerHTML = `${page.shortBody}`; + result.appendChild(description); + + this.nodes.searchResultWrapper.appendChild(result); + }); + } + + // showSuggestedWordCompletion(word) { + // const typedString = this.nodes.searchInput.value; + // const words = typedString.split(' '); + // + // words.pop(); + // words.push(word); + // + // this.nodes.searchInput.value = words.join(' '); + // + // this.nodes.searchInput.select(); + // this.nodes.searchInput.selectionStart = typedString.length; + // this.nodes.searchInput.selectionEnd = this.nodes.searchInput.value.length; + // } + + // generateSearchSuggestions(suggestions = []) { + // const suggestionsWrapper = document.createElement('div'); + // + // suggestionsWrapper.classList.add(this.CSS.searchResultSuggestions); + // + // suggestions.forEach(suggestion => { + // const suggestionItem = document.createElement('span'); + // + // suggestionItem.classList.add(this.CSS.searchResultSuggestionItem); + // suggestionItem.innerHTML = suggestion; + // suggestionItem.addEventListener('click', this.searchSuggestionClickProcessor.bind(this)); + // + // suggestionsWrapper.appendChild(suggestionItem); + // }); + // + // return suggestionsWrapper; + // } + // + // searchSuggestionClickProcessor(event) { + // const word = event.target.innerHTML; + // + // const searchString = this.nodes.searchInput.value; + // const searchStringWords = searchString.split(' '); + // + // searchStringWords.pop(); + // searchStringWords.push(word); + // + // this.nodes.searchInput.value = searchStringWords.join(' '); + // + // this.debouncedSearch(this.nodes.searchInput.value); + // } +} diff --git a/src/frontend/styles/components/search.pcss b/src/frontend/styles/components/search.pcss new file mode 100644 index 0000000..69239c6 --- /dev/null +++ b/src/frontend/styles/components/search.pcss @@ -0,0 +1,119 @@ +.search { + &-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + display: none; + overflow-y: scroll; + + z-index: 10; + + cursor: pointer; + + &--visible { + display: block; + } + } + + &-wrapper { + background: #fff; + margin: 30vh auto 20vh; + max-width: var(--layout-width-main-col); + width: 100%; + + border-radius: 8px; + box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.1); + + overflow: hidden; + } + + &-input { + width: 100%; + background: #bbb; + padding: 15px 25px; + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + outline: none; + background-color: transparent; + + &::placeholder { + font-weight: 500; + } + } + + &-result { + &-wrapper { + + } + + &-suggestions { + padding: 5px 25px; + overflow-x: scroll; + + &-item { + margin-right: 12px; + + padding: 2px 8px; + background: #ccedf6; + border-radius: 50px; + border: 1px solid #b5d8e2; + + &:hover { + background: #aad4e0; + } + } + } + + &-item { + padding: 15px 25px; + display: block; + + &:nth-child(odd) { + background: #f5f5f5; + } + + &:hover { + background: #ddd; + } + + &__title { + font-weight: 500; + } + + &__section { + opacity: 0.7; + + &:before { + content: '•'; + margin: 0 5px; + text-align: center; + } + } + + &__description { + margin-top: 8px; + line-height: 1.5; + } + } + } + + &-word { + color: var(--color-link-active); + } +} + +.noscroll { + overflow: hidden; +} + +.blurred { + filter: blur(3px); +} + + diff --git a/src/frontend/styles/main.pcss b/src/frontend/styles/main.pcss index c436ade..6a267ab 100644 --- a/src/frontend/styles/main.pcss +++ b/src/frontend/styles/main.pcss @@ -13,6 +13,7 @@ @import './components/sidebar.pcss'; @import './components/navigator.pcss'; @import './components/table-of-content.pcss'; +@import './components/search.pcss'; body { font-family: system-ui, Helvetica, Arial, Verdana; diff --git a/yarn.lock b/yarn.lock index c25a97d..28bbe37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1732,6 +1732,14 @@ axios@^0.21.1: dependencies: follow-redirects "^1.14.0" +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + babel-eslint@^10.0.1: version "10.1.0" resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232" @@ -2080,7 +2088,8 @@ clone-response@^1.0.2: clone@2.x: version "2.1.2" - resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== code-point-at@^1.0.0: version "1.1.0" @@ -3162,7 +3171,7 @@ flatten@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" -follow-redirects@^1.14.0: +follow-redirects@^1.14.0, follow-redirects@^1.14.9: version "1.15.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" @@ -3193,6 +3202,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + formdata-polyfill@^4.0.10: version "4.0.10" resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" @@ -4310,7 +4328,8 @@ nise@^5.1.1: node-cache@^5.1.2: version "5.1.2" - resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== dependencies: clone "2.x"