diff --git a/package.json b/package.json index 4dfaa26909..e28b5c6ad3 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ "graphql:generate:thegraph": "graphql-codegen --config graphql.thegraph.codegen.config.ts", "graphql:generate": "yarn graphql:generate:data && yarn graphql:generate:thegraph", "graphql": "yarn graphql:fetch && yarn graphql:generate", + "sitemap:generate": "node scripts/generate-sitemap.js", "i18n:extract": "lingui extract --locale en-US", "i18n:compile": "lingui compile", "i18n": "yarn i18n:extract --clean && yarn i18n:compile", - "prepare": "concurrently \"npm:ajv\" \"npm:contracts\" \"npm:graphql\" \"npm:i18n\"", + "prepare": "concurrently \"npm:ajv\" \"npm:contracts\" \"npm:graphql\" \"npm:i18n\" \"npm:sitemap:generate\"", "start": "craco start", "start:cloud": "NODE_OPTIONS=--dns-result-order=ipv4first PORT=3001 npx wrangler pages dev --compatibility-flags=nodejs_compat --compatibility-date=2023-08-01 --proxy=3001 --port=3000 -- yarn start", "build": "craco build", @@ -114,6 +115,7 @@ "@types/ua-parser-js": "^0.7.36", "@types/uuid": "^8.3.4", "@types/wcag-contrast": "^3.0.0", + "@types/xml2js": "^0.4.12", "@uniswap/default-token-list": "^11.2.0", "@uniswap/eslint-config": "^1.2.0", "@vanilla-extract/jest-transform": "^1.1.1", @@ -293,6 +295,7 @@ "workbox-navigation-preload": "^6.1.0", "workbox-precaching": "^6.1.0", "workbox-routing": "^6.1.0", + "xml2js": "^0.6.2", "zustand": "^4.3.6" }, "engines": { diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000000..ca9f947bee --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/generate-sitemap.js b/scripts/generate-sitemap.js new file mode 100644 index 0000000000..64889262d9 --- /dev/null +++ b/scripts/generate-sitemap.js @@ -0,0 +1,25 @@ +/* eslint-env node */ + +const fs = require('fs') +const { parseStringPromise, Builder } = require('xml2js') + +fs.readFile('./public/sitemap.xml', 'utf8', async (err, data) => { + try { + const sitemap = await parseStringPromise(data) + + const lastmodDate = new Date().toISOString() + if (sitemap.urlset.url) { + sitemap.urlset.url.forEach((url) => { + url['$'].lastmod = lastmodDate + }) + } + const builder = new Builder() + const xml = builder.buildObject(sitemap) + fs.writeFile('./public/sitemap.xml', xml, (error) => { + if (error) throw error + console.log('Sitemap updated') + }) + } catch { + throw new Error('Error parsing sitemap.xml') + } +}) diff --git a/src/pages/App.tsx b/src/pages/App.tsx index d7f18e7009..17d3023a05 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -5,51 +5,27 @@ import ErrorBoundary from 'components/ErrorBoundary' import Loader from 'components/Icons/LoadingSpinner' import NavBar, { PageTabs } from 'components/NavBar' import { useFeatureFlagsIsLoaded } from 'featureFlags' -import { useInfoPoolPageEnabled } from 'featureFlags/flags/infoPoolPage' import { useAtom } from 'jotai' import { useBag } from 'nft/hooks/useBag' import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useState } from 'react' -import { Navigate, Route, Routes, useLocation, useSearchParams } from 'react-router-dom' +import { Route, Routes, useLocation, useSearchParams } from 'react-router-dom' import { shouldDisableNFTRoutesAtom } from 'state/application/atoms' import { useRouterPreference } from 'state/user/hooks' import { StatsigProvider, StatsigUser } from 'statsig-react' import styled from 'styled-components' -import { SpinnerSVG } from 'theme/components' import DarkModeQueryParamReader from 'theme/components/DarkModeQueryParamReader' import { useIsDarkMode } from 'theme/components/ThemeToggle' import { flexRowNoWrap } from 'theme/styles' import { Z_INDEX } from 'theme/zIndex' import { STATSIG_DUMMY_KEY } from 'tracing' -import { getEnvName, isBrowserRouterEnabled } from 'utils/env' +import { getEnvName } from 'utils/env' import { getDownloadAppLink } from 'utils/openDownloadApp' import { getCurrentPageFromLocation } from 'utils/urlRoutes' import { getCLS, getFCP, getFID, getLCP, Metric } from 'web-vitals' -// High-traffic pages (index and /swap) should not be lazy-loaded. -import Landing from './Landing' -import Swap from './Swap' +import { RouteDefinition, routes, useRouterConfig } from './RouteDefinitions' const AppChrome = lazy(() => import('./AppChrome')) -const NftExplore = lazy(() => import('nft/pages/explore')) -const Collection = lazy(() => import('nft/pages/collection')) -const Profile = lazy(() => import('nft/pages/profile')) -const Asset = lazy(() => import('nft/pages/asset/Asset')) -const AddLiquidity = lazy(() => import('pages/AddLiquidity')) -const RedirectDuplicateTokenIds = lazy(() => import('pages/AddLiquidity/redirects')) -const RedirectDuplicateTokenIdsV2 = lazy(() => import('pages/AddLiquidityV2/redirects')) -const MigrateV2 = lazy(() => import('pages/MigrateV2')) -const MigrateV2Pair = lazy(() => import('pages/MigrateV2/MigrateV2Pair')) -const NotFound = lazy(() => import('pages/NotFound')) -const Pool = lazy(() => import('pages/Pool')) -const PositionPage = lazy(() => import('pages/Pool/PositionPage')) -const PoolV2 = lazy(() => import('pages/Pool/v2')) -const PoolDetails = lazy(() => import('pages/PoolDetails')) -const PoolFinder = lazy(() => import('pages/PoolFinder')) -const RemoveLiquidity = lazy(() => import('pages/RemoveLiquidity')) -const RemoveLiquidityV3 = lazy(() => import('pages/RemoveLiquidity/V3')) -const TokenDetails = lazy(() => import('pages/TokenDetails')) -const Tokens = lazy(() => import('pages/Tokens')) -const Vote = lazy(() => import('pages/Vote')) const BodyWrapper = styled.div` display: flex; @@ -93,32 +69,18 @@ const HeaderWrapper = styled.div<{ transparent?: boolean }>` z-index: ${Z_INDEX.dropdown}; ` -// this is the same svg defined in assets/images/blue-loader.svg -// it is defined here because the remote asset may not have had time to load when this file is executing -const LazyLoadSpinner = () => ( - - - -) - export default function App() { const isLoaded = useFeatureFlagsIsLoaded() - const [shouldDisableNFTRoutes, setShouldDisableNFTRoutes] = useAtom(shouldDisableNFTRoutesAtom) + const [, setShouldDisableNFTRoutes] = useAtom(shouldDisableNFTRoutesAtom) - const browserRouterEnabled = isBrowserRouterEnabled() const location = useLocation() - const { hash, pathname } = location + const { pathname } = location const currentPage = getCurrentPageFromLocation(pathname) const isDarkMode = useIsDarkMode() const [routerPreference] = useRouterPreference() const [scrolledState, setScrolledState] = useState(false) - const infoPoolPageEnabled = useInfoPoolPageEnabled() + + const routerConfig = useRouterConfig() useEffect(() => { window.scrollTo(0, 0) @@ -224,116 +186,15 @@ export default function App() { }> {isLoaded ? ( - : - } - /> - - }> - - - } /> - {infoPoolPageEnabled && } />} - }> - - - } - /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - }> - - - - }> - {/* this is workaround since react-router-dom v6 doesn't support optional parameters any more */} - - - - - - }> - - - - - - - } /> - } /> - - } /> - } /> - - {!shouldDisableNFTRoutes && ( - <> - - - - } - /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - + {routes.map((route: RouteDefinition) => + route.enabled(routerConfig) ? ( + + {route.nestedPaths.map((nestedPath) => ( + + ))} + + ) : null )} - - } /> - } /> ) : ( diff --git a/src/pages/RouteDefinitions.tsx b/src/pages/RouteDefinitions.tsx new file mode 100644 index 0000000000..b87d31a53f --- /dev/null +++ b/src/pages/RouteDefinitions.tsx @@ -0,0 +1,208 @@ +import { useInfoPoolPageEnabled } from 'featureFlags/flags/infoPoolPage' +import { useAtom } from 'jotai' +import { lazy, ReactNode, Suspense, useMemo } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { shouldDisableNFTRoutesAtom } from 'state/application/atoms' +import { SpinnerSVG } from 'theme/components' +import { isBrowserRouterEnabled } from 'utils/env' + +// High-traffic pages (index and /swap) should not be lazy-loaded. +import Landing from './Landing' +import Swap from './Swap' + +const NftExplore = lazy(() => import('nft/pages/explore')) +const Collection = lazy(() => import('nft/pages/collection')) +const Profile = lazy(() => import('nft/pages/profile')) +const Asset = lazy(() => import('nft/pages/asset/Asset')) +const AddLiquidity = lazy(() => import('pages/AddLiquidity')) +const RedirectDuplicateTokenIds = lazy(() => import('pages/AddLiquidity/redirects')) +const RedirectDuplicateTokenIdsV2 = lazy(() => import('pages/AddLiquidityV2/redirects')) +const MigrateV2 = lazy(() => import('pages/MigrateV2')) +const MigrateV2Pair = lazy(() => import('pages/MigrateV2/MigrateV2Pair')) +const NotFound = lazy(() => import('pages/NotFound')) +const Pool = lazy(() => import('pages/Pool')) +const PositionPage = lazy(() => import('pages/Pool/PositionPage')) +const PoolV2 = lazy(() => import('pages/Pool/v2')) +const PoolDetails = lazy(() => import('pages/PoolDetails')) +const PoolFinder = lazy(() => import('pages/PoolFinder')) +const RemoveLiquidity = lazy(() => import('pages/RemoveLiquidity')) +const RemoveLiquidityV3 = lazy(() => import('pages/RemoveLiquidity/V3')) +const TokenDetails = lazy(() => import('pages/TokenDetails')) +const Tokens = lazy(() => import('pages/Tokens')) +const Vote = lazy(() => import('pages/Vote')) + +// this is the same svg defined in assets/images/blue-loader.svg +// it is defined here because the remote asset may not have had time to load when this file is executing +const LazyLoadSpinner = () => ( + + + +) + +interface RouterConfig { + browserRouterEnabled?: boolean + hash?: string + infoPoolPageEnabled?: boolean + shouldDisableNFTRoutes?: boolean +} + +/** + * Convenience hook which organizes the router configuration into a single object. + */ +export function useRouterConfig(): RouterConfig { + const browserRouterEnabled = isBrowserRouterEnabled() + const { hash } = useLocation() + const infoPoolPageEnabled = useInfoPoolPageEnabled() + const [shouldDisableNFTRoutes] = useAtom(shouldDisableNFTRoutesAtom) + return useMemo( + () => ({ + browserRouterEnabled, + hash, + infoPoolPageEnabled, + shouldDisableNFTRoutes: Boolean(shouldDisableNFTRoutes), + }), + [browserRouterEnabled, hash, infoPoolPageEnabled, shouldDisableNFTRoutes] + ) +} + +export interface RouteDefinition { + path: string + nestedPaths: string[] + enabled: (args: RouterConfig) => boolean + getElement: (args: RouterConfig) => ReactNode +} + +// Assigns the defaults to the route definition. +function createRouteDefinition(route: Partial): RouteDefinition { + return { + getElement: () => null, + enabled: () => true, + path: '/', + nestedPaths: [], + // overwrite the defaults + ...route, + } +} + +export const routes: RouteDefinition[] = [ + createRouteDefinition({ + path: '/', + getElement: (args) => { + return args.browserRouterEnabled && args.hash ? : + }, + }), + createRouteDefinition({ + path: '/tokens', + nestedPaths: [':chainName'], + getElement: () => , + }), + createRouteDefinition({ path: '/tokens/:chainName/:tokenAddress', getElement: () => }), + createRouteDefinition({ + path: '/pools/:chainName/:poolAddress', + getElement: () => , + enabled: (args) => Boolean(args.infoPoolPageEnabled), + }), + createRouteDefinition({ + path: '/vote/*', + getElement: () => ( + }> + + + ), + }), + createRouteDefinition({ + path: '/create-proposal', + getElement: () => , + }), + createRouteDefinition({ + path: '/send', + getElement: () => , + }), + createRouteDefinition({ path: '/swap', getElement: () => }), + createRouteDefinition({ path: '/pool/v2/find', getElement: () => }), + createRouteDefinition({ path: '/pool/v2', getElement: () => }), + createRouteDefinition({ path: '/pool', getElement: () => }), + createRouteDefinition({ path: '/pool/:tokenId', getElement: () => }), + createRouteDefinition({ path: '/pools/v2/find', getElement: () => }), + createRouteDefinition({ path: '/pools/v2', getElement: () => }), + createRouteDefinition({ path: '/pools', getElement: () => }), + createRouteDefinition({ path: '/pools/:tokenId', getElement: () => }), + createRouteDefinition({ + path: '/add/v2', + nestedPaths: [':currencyIdA', ':currencyIdA/:currencyIdB'], + getElement: () => , + }), + createRouteDefinition({ + path: '/add', + nestedPaths: [':currencyIdA', ':currencyIdA/:currencyIdB', ':currencyIdA/:currencyIdB/:feeAmount'], + getElement: () => , + }), + + createRouteDefinition({ + path: '/increase', + nestedPaths: [ + ':currencyIdA', + ':currencyIdA/:currencyIdB', + ':currencyIdA/:currencyIdB/:feeAmount', + ':currencyIdA/:currencyIdB/:feeAmount/:tokenId', + ], + getElement: () => , + }), + createRouteDefinition({ path: '/remove/v2/:currencyIdA/:currencyIdB', getElement: () => }), + createRouteDefinition({ path: '/remove/:tokenId', getElement: () => }), + createRouteDefinition({ path: '/migrate/v2', getElement: () => }), + createRouteDefinition({ path: '/migrate/v2/:address', getElement: () => }), + createRouteDefinition({ + path: '/nfts', + getElement: () => ( + + + + ), + enabled: (args) => !args.shouldDisableNFTRoutes, + }), + createRouteDefinition({ + path: '/nfts/asset/:contractAddress/:tokenId', + getElement: () => ( + + + + ), + enabled: (args) => !args.shouldDisableNFTRoutes, + }), + createRouteDefinition({ + path: '/nfts/profile', + getElement: () => ( + + + + ), + enabled: (args) => !args.shouldDisableNFTRoutes, + }), + createRouteDefinition({ + path: '/nfts/collection/:contractAddress', + getElement: () => ( + + + + ), + enabled: (args) => !args.shouldDisableNFTRoutes, + }), + createRouteDefinition({ + path: '/nfts/collection/:contractAddress/activity', + getElement: () => ( + + + + ), + enabled: (args) => !args.shouldDisableNFTRoutes, + }), + createRouteDefinition({ path: '*', getElement: () => }), + createRouteDefinition({ path: '/not-found', getElement: () => }), +] diff --git a/src/pages/__snapshots__/routes.test.ts.snap b/src/pages/__snapshots__/routes.test.ts.snap new file mode 100644 index 0000000000..1e87906873 --- /dev/null +++ b/src/pages/__snapshots__/routes.test.ts.snap @@ -0,0 +1,200 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Routes router definition should match snapshot 1`] = ` +Array [ + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [ + ":chainName", + ], + "path": "/tokens", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/tokens/:chainName/:tokenAddress", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pools/:chainName/:poolAddress", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/vote/*", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/create-proposal", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/send", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/swap", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pool/v2/find", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pool/v2", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pool", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pool/:tokenId", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pools/v2/find", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pools/v2", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pools", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/pools/:tokenId", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [ + ":currencyIdA", + ":currencyIdA/:currencyIdB", + ], + "path": "/add/v2", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [ + ":currencyIdA", + ":currencyIdA/:currencyIdB", + ":currencyIdA/:currencyIdB/:feeAmount", + ], + "path": "/add", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [ + ":currencyIdA", + ":currencyIdA/:currencyIdB", + ":currencyIdA/:currencyIdB/:feeAmount", + ":currencyIdA/:currencyIdB/:feeAmount/:tokenId", + ], + "path": "/increase", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/remove/v2/:currencyIdA/:currencyIdB", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/remove/:tokenId", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/migrate/v2", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/migrate/v2/:address", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/nfts", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/nfts/asset/:contractAddress/:tokenId", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/nfts/profile", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/nfts/collection/:contractAddress", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/nfts/collection/:contractAddress/activity", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "*", + }, + Object { + "enabled": [Function], + "getElement": [Function], + "nestedPaths": Array [], + "path": "/not-found", + }, +] +`; diff --git a/src/pages/routes.test.ts b/src/pages/routes.test.ts new file mode 100644 index 0000000000..927925b92b --- /dev/null +++ b/src/pages/routes.test.ts @@ -0,0 +1,26 @@ +import fs from 'fs' +import { parseStringPromise } from 'xml2js' + +import { routes } from './RouteDefinitions' + +describe('Routes', () => { + it('sitemap URLs should exist as Router paths', async () => { + const pathNames: string[] = routes.map((routeDef) => routeDef.path) + const contents = fs.readFileSync('./public/sitemap.xml', 'utf8') + const sitemap = await parseStringPromise(contents) + + const sitemapPaths = sitemap.urlset.url.map((url: any) => new URL(url['$'].loc).pathname) + + sitemapPaths.forEach((path: string) => { + expect(pathNames).toContain(path) + }) + }) + + /** + * If you are updating the app routes, consider if you need to make a + * corresponding update to the sitemap.xml file. + */ + it('router definition should match snapshot', () => { + expect(routes).toMatchSnapshot() + }) +}) diff --git a/yarn.lock b/yarn.lock index e8435e0e72..97c884f672 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5921,6 +5921,13 @@ dependencies: "@types/node" "*" +"@types/xml2js@^0.4.12": + version "0.4.12" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.12.tgz#d9aae03295476fd5cbc74e0b572816208dbec6d1" + integrity sha512-CZPpQKBZ8db66EP5hCjwvYrLThgZvnyZrPXK2W+UI1oOaWezGt34iOaUCX4Jah2X8+rQqjvl9VKEIT8TR1I0rA== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -15542,9 +15549,9 @@ mz@^2.7.0: thenify-all "^1.0.0" nan@^2.14.0: - version "2.14.2" - resolved "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz" - integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== + version "2.18.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.18.0.tgz#26a6faae7ffbeb293a39660e88a76b82e30b7554" + integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w== nano-time@1.0.0: version "1.0.0" @@ -17227,9 +17234,9 @@ punycode@1.3.2, punycode@^1.3.2: integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= punycode@^2.1.0, punycode@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + version "2.3.0" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" + integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== pure-rand@^6.0.0: version "6.0.2" @@ -21101,6 +21108,14 @@ xml2js@^0.4.5: sax ">=0.6.0" xmlbuilder "~11.0.0" +xml2js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" + integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + xmlbuilder@~11.0.0: version "11.0.1" resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz"