diff --git a/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts new file mode 100644 index 0000000000..2c258b0e50 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-multisite/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts @@ -0,0 +1,33 @@ +import { + MultisiteGraphQLSitemapService, + StaticPath, + constants, + SiteInfo, +} from '@sitecore-jss/sitecore-jss-nextjs'; +import config from 'temp/config'; +import { SitemapFetcherPlugin } from '..'; +import { GetStaticPathsContext } from 'next'; +import { siteResolver } from 'lib/site-resolver'; + +class GraphqlSitemapServicePlugin implements SitemapFetcherPlugin { + _graphqlSitemapService: MultisiteGraphQLSitemapService; + + constructor() { + this._graphqlSitemapService = new MultisiteGraphQLSitemapService({ + endpoint: config.graphQLEndpoint, + apiKey: config.sitecoreApiKey, + sites: [...new Set(siteResolver.sites.map((site: SiteInfo) => site.name))], + }); + } + + async exec(context?: GetStaticPathsContext): Promise { + if (process.env.JSS_MODE === constants.JSS_MODE.DISCONNECTED) { + return []; + } + return process.env.EXPORT_MODE + ? this._graphqlSitemapService.fetchExportSitemap(config.defaultLanguage) + : this._graphqlSitemapService.fetchSSGSitemap(context?.locales || []); + } +} + +export const graphqlSitemapServicePlugin = new GraphqlSitemapServicePlugin(); diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts index b5e67c0ea4..ea50d1c09e 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/lib/sitemap-fetcher/plugins/graphql-sitemap-service.ts @@ -2,12 +2,10 @@ GraphQLSitemapService, StaticPath, constants, - SiteInfo, } from '@sitecore-jss/sitecore-jss-nextjs'; import config from 'temp/config'; import { SitemapFetcherPlugin } from '..'; import { GetStaticPathsContext } from 'next'; -import { siteResolver } from 'lib/site-resolver'; class GraphqlSitemapServicePlugin implements SitemapFetcherPlugin { _graphqlSitemapService: GraphQLSitemapService; @@ -16,18 +14,17 @@ class GraphqlSitemapServicePlugin implements SitemapFetcherPlugin { this._graphqlSitemapService = new GraphQLSitemapService({ endpoint: config.graphQLEndpoint, apiKey: config.sitecoreApiKey, - sites: siteResolver.sites.map((site: SiteInfo) => site.name), + siteName: config.jssAppName, }); } async exec(context?: GetStaticPathsContext): Promise { - if (process.env.EXPORT_MODE) { - // Disconnected Export mode - if (process.env.JSS_MODE !== constants.JSS_MODE.DISCONNECTED) { - return this._graphqlSitemapService.fetchExportSitemap(config.defaultLanguage); - } + if (process.env.JSS_MODE === constants.JSS_MODE.DISCONNECTED) { + return []; } - return this._graphqlSitemapService.fetchSSGSitemap(context?.locales || []); + return process.env.EXPORT_MODE + ? this._graphqlSitemapService.fetchExportSitemap(config.defaultLanguage) + : this._graphqlSitemapService.fetchSSGSitemap(context?.locales || []); } } diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index 93b1e290c8..097630aeaa 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -80,6 +80,11 @@ export { GraphQLSitemapServiceConfig, } from './services/graphql-sitemap-service'; +export { + MultisiteGraphQLSitemapService, + MultisiteGraphQLSitemapServiceConfig, +} from './services/mutisite-graphql-sitemap-service'; + export { GraphQLSitemapXmlService, GraphQLSitemapXmlServiceConfig, diff --git a/packages/sitecore-jss-nextjs/src/services/base-graphql-sitemap-service.ts b/packages/sitecore-jss-nextjs/src/services/base-graphql-sitemap-service.ts new file mode 100644 index 0000000000..61b1fec4fe --- /dev/null +++ b/packages/sitecore-jss-nextjs/src/services/base-graphql-sitemap-service.ts @@ -0,0 +1,333 @@ +import { GraphQLClient, GraphQLRequestClient, PageInfo } from '@sitecore-jss/sitecore-jss/graphql'; +import { debug } from '@sitecore-jss/sitecore-jss'; +import { getPersonalizedRewrite } from '@sitecore-jss/sitecore-jss/personalize'; + +/** @private */ +export const languageError = 'The list of languages cannot be empty'; +export const siteError = 'The service needs a site name'; + +/** + * @param {string} siteName to inject into error text + * @private + */ +export function getSiteEmptyError(siteName: string) { + return `Site "${siteName}" does not exist or site item tree is missing`; +} + +const languageEmptyError = 'The language must be a non-empty string'; + +/** + * GQL query made dynamic based on schema differences between SXP and XM Cloud + * @param {boolean} usesPersonalize flag to detrmine which variation of a query to run + * @returns GraphQL query to fetch site paths with + */ +const defaultQuery = (usesPersonalize?: boolean) => /* GraphQL */ ` +query ${usesPersonalize ? 'PersonalizeSitemapQuery' : 'DefaultSitemapQuery'}( + $siteName: String! + $language: String! + $includedPaths: [String] + $excludedPaths: [String] + $pageSize: Int = 10 + $after: String +) { + site { + siteInfo(site: $siteName) { + routes( + language: $language + includedPaths: $includedPaths + excludedPaths: $excludedPaths + first: $pageSize + after: $after + ){ + total + pageInfo { + endCursor + hasNext + } + results { + path: routePath + ${ + usesPersonalize + ? ` + route { + personalization { + variantIds + } + }` + : '' + } + } + } + } + } +} +`; +/** + * type for input variables for the site routes query + */ +interface SiteRouteQueryVariables { + /** + * Required. The name of the site being queried. + */ + siteName: string; + /** + * Required. The language to return routes/pages for. + */ + language: string; + /** + * Optional. Only paths starting with these provided prefixes will be returned. + */ + includedPaths?: string[]; + /** + * Optional. Paths starting with these provided prefixes will be excluded from returned results. + */ + excludedPaths?: string[]; + + /** common variable for all GraphQL queries + * it will be used for every type of query to regulate result batch size + * Optional. How many result items to fetch in each GraphQL call. This is needed for pagination. + * @default 10 + */ + pageSize?: number; +} + +/** + * Schema of data returned in response to a "site" query request + * @template T The type of objects being requested. + */ +export interface SiteRouteQueryResult { + site: { + siteInfo: { + routes: { + /** + * Data needed to paginate the site results + */ + pageInfo: PageInfo; + results: T[]; + }; + }; + }; +} + +/** + * The schema of data returned in response to a routes list query request + */ +export type RouteListQueryResult = { + path: string; + route?: { + personalization?: { + variantIds: string[]; + }; + }; +}; + +/** + * Configuration options for @see GraphQLSitemapService instances + */ +export interface BaseGraphQLSitemapServiceConfig + extends Omit { + /** + * Your Graphql endpoint + */ + endpoint: string; + + /** + * The API key to use for authentication. + */ + apiKey: string; + + /** + * A flag for whether to include personalized routes in service output - only works on XM Cloud + * turned off by default + */ + includePersonalizedRoutes?: boolean; +} + +/** + * Object model of a site page item. + */ +export type StaticPath = { + params: { + path: string[]; + }; + locale?: string; +}; + +/** + * Service that fetches the list of site pages using Sitecore's GraphQL API. + * Used to handle a single site + * This list is used for SSG and Export functionality. + * @mixes SearchQueryService + */ +export abstract class BaseGraphQLSitemapService { + private _graphQLClient: GraphQLClient; + + /** + * GraphQL client accessible by descendant classes when needed + */ + protected get graphQLClient() { + return this._graphQLClient; + } + + /** + * Gets the default query used for fetching the list of site pages + */ + protected get query(): string { + return defaultQuery(this.options.includePersonalizedRoutes); + } + + /** + * Creates an instance of graphQL sitemap service with the provided options + * @param {GraphQLSitemapServiceConfig} options instance + */ + constructor(public options: BaseGraphQLSitemapServiceConfig) { + this._graphQLClient = this.getGraphQLClient(); + } + + /** + * Fetch sitemap which could be used for generation of static pages during `next export`. + * The `locale` parameter will be used in the item query, but since i18n is not supported, + * the output paths will not include a `language` property. + * @param {string} locale which application supports + * @returns an array of @see StaticPath objects + */ + async fetchExportSitemap(locale: string): Promise { + const formatPath = (path: string[]) => ({ + params: { + path, + }, + }); + + return this.fetchSitemap([locale], formatPath); + } + + /** + * Fetch sitemap which could be used for generation of static pages using SSG mode + * @param {string[]} locales locales which application supports + * @returns an array of @see StaticPath objects + */ + async fetchSSGSitemap(locales: string[]): Promise { + const formatPath = (path: string[], locale: string) => ({ + params: { + path, + }, + locale, + }); + + return this.fetchSitemap(locales, formatPath); + } + + protected async getTranformedPaths( + siteName: string, + languages: string[], + formatStaticPath: (path: string[], language: string) => StaticPath + ) { + const paths = new Array(); + await Promise.all( + languages.map(async (language) => { + if (language === '') { + throw new RangeError(languageEmptyError); + } + debug.sitemap('fetching sitemap data for %s %s', language, siteName); + const results = await this.fetchLanguageSitePaths(language, siteName); + const transformedPaths = await this.transformLanguageSitePaths( + results, + formatStaticPath, + language + ); + paths.push(...transformedPaths); + }) + ); + return paths; + } + + protected async transformLanguageSitePaths( + sitePaths: RouteListQueryResult[], + formatStaticPath: (path: string[], language: string) => StaticPath, + language: string + ): Promise { + const formatPath = (path: string) => + formatStaticPath(path.replace(/^\/|\/$/g, '').split('/'), language); + + const aggregatedPaths: StaticPath[] = []; + + sitePaths.forEach((item) => { + if (!item) return; + + aggregatedPaths.push(formatPath(item.path)); + + // check for type safety's sake - personalize may be empty depending on query type + if (item.route?.personalization?.variantIds.length) { + aggregatedPaths.push( + ...(item.route?.personalization?.variantIds.map((varId) => + formatPath(getPersonalizedRewrite(item.path, { variantId: varId })) + ) || {}) + ); + } + }); + + return aggregatedPaths; + } + + protected async fetchLanguageSitePaths( + language: string, + siteName: string + ): Promise { + const args: SiteRouteQueryVariables = { + siteName: siteName, + language: language, + pageSize: this.options.pageSize, + includedPaths: this.options.includedPaths, + excludedPaths: this.options.excludedPaths, + }; + let results: RouteListQueryResult[] = []; + let hasNext = true; + let after = ''; + + while (hasNext) { + const fetchResponse = await this.graphQLClient.request< + SiteRouteQueryResult + >(this.query, { + ...args, + after, + }); + + if (!fetchResponse?.site?.siteInfo) { + throw new RangeError(getSiteEmptyError(siteName)); + } else { + results = results.concat(fetchResponse.site.siteInfo.routes?.results); + hasNext = fetchResponse.site.siteInfo.routes?.pageInfo.hasNext; + after = fetchResponse.site.siteInfo.routes?.pageInfo.endCursor; + } + } + + return results; + } + + /** + * Gets a GraphQL client that can make requests to the API. Uses graphql-request as the default + * library for fetching graphql data (@see GraphQLRequestClient). Override this method if you + * want to use something else. + * @returns {GraphQLClient} implementation + */ + protected getGraphQLClient(): GraphQLClient { + return new GraphQLRequestClient(this.options.endpoint, { + apiKey: this.options.apiKey, + debugger: debug.sitemap, + }); + } + + /** + * Fetch a flat list of all pages that belong to the specificed site and have a + * version in the specified language(s). + * @param {string[]} languages Fetch pages that have versions in this language(s). + * @param {Function} formatStaticPath Function for transforming the raw search results into (@see StaticPath) types. + * @returns list of pages + * @throws {RangeError} if the list of languages is empty. + * @throws {RangeError} if the any of the languages is an empty string. + */ + protected abstract fetchSitemap( + languages: string[], + formatStaticPath: (path: string[], language: string) => StaticPath + ): Promise; +} diff --git a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts index f517e37496..2ec30b73f8 100644 --- a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts +++ b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.test.ts @@ -8,7 +8,7 @@ import { } from './graphql-sitemap-service'; import sitemapDefaultQueryResult from '../test-data/sitemapDefaultQueryResult.json'; import sitemapPersonalizeQueryResult from '../test-data/sitemapPersonalizeQueryResult.json'; -import sitemapServiceResult from '../test-data/sitemapServiceResult'; +import sitemapServiceSinglesiteResult from '../test-data/sitemapServiceSinglesiteResult'; import { GraphQLClient, GraphQLRequestClient } from '@sitecore-jss/sitecore-jss/graphql'; class TestService extends GraphQLSitemapService { @@ -51,7 +51,7 @@ it('should return null if no app root found', async () => { describe('GraphQLSitemapService', () => { const endpoint = 'http://site'; const apiKey = 'some-api-key'; - const sites = ['site-name']; + const siteName = 'site-name'; afterEach(() => { nock.cleanAll(); @@ -86,9 +86,9 @@ describe('GraphQLSitemapService', () => { it('should work when 1 language is requested', async () => { mockPathsRequest(); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); const sitemap = await service.fetchSSGSitemap(['ua']); - expect(sitemap).to.deep.equal(sitemapServiceResult); + expect(sitemap).to.deep.equal(sitemapServiceSinglesiteResult); return expect(nock.isDone()).to.be.true; }); @@ -155,7 +155,7 @@ describe('GraphQLSitemapService', () => { const service = new GraphQLSitemapService({ endpoint, apiKey, - sites, + siteName, includedPaths, excludedPaths, }); @@ -171,572 +171,315 @@ describe('GraphQLSitemapService', () => { ]); }); - it('should return personalized paths when personalize data is requested and returned', async () => { - const lang = 'ua'; + describe('Fetch sitemap in SSG mode', () => { + it('should work when 1 language is requested', async () => { + mockPathsRequest(); - nock(endpoint) - .post('/', /PersonalizeSitemapQuery/gi) - .reply(200, sitemapPersonalizeQueryResult); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemap = await service.fetchSSGSitemap(['ua']); + expect(sitemap).to.deep.equal(sitemapServiceSinglesiteResult); - const service = new GraphQLSitemapService({ - endpoint, - apiKey, - sites, - includePersonalizedRoutes: true, + return expect(nock.isDone()).to.be.true; }); - const sitemap = await service.fetchSSGSitemap([lang]); - expect(sitemap).to.deep.equal([ - { - params: { - path: [''], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_green'], - }, - locale: lang, - }, - { - params: { - path: ['y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_green', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_red', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_purple', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - ]); - return expect(nock.isDone()).to.be.true; - }); + it('should work for single site when 1 language is requested', async () => { + mockPathsRequest(); - it('should return aggregated paths for multiple sites when no personalized site', async () => { - const multipleSites = ['site1', 'site2']; - const lang = 'ua'; + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemap = await service.fetchSSGSitemap(['ua']); + expect(sitemap).to.deep.equal(sitemapServiceSinglesiteResult); - nock(endpoint) - .persist() - .post('/', (body) => { - return body.variables.siteName === multipleSites[0]; - }) - .reply(200, { - data: { - site: { - siteInfo: { - routes: { - total: 4, - pageInfo: { - hasNext: false, - }, - results: [ - { - path: '/', - }, - { - path: '/x1', - }, - { - path: '/y1/y2/y3/y4', - }, - { - path: '/y1/y2', + return expect(nock.isDone()).to.be.true; + }); + + it('should work when includePaths and excludePaths are provided', async () => { + const includedPaths = ['/y1/']; + const excludedPaths = ['/y1/y2/y3/y4']; + + nock(endpoint) + .post('/', (body) => { + return body.variables.includedPaths && body.variables.excludedPaths; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 1, + pageInfo: { + hasNext: false, }, - ], + results: [ + { + path: '/y1/y2/', + }, + ], + }, }, }, }, - }, - }); - - nock(endpoint) - .persist() - .post('/', (body) => { - return body.variables.siteName === multipleSites[1]; - }) - .reply(200, { - data: { - site: { - siteInfo: { - routes: { - total: 2, - pageInfo: { - hasNext: false, - }, - results: [ - { - path: '/y1', - }, - { - path: '/x1/x2', + }); + + nock(endpoint) + .post('/') + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, }, - ], + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, }, }, }, - }, + }); + + const service = new GraphQLSitemapService({ + endpoint, + apiKey, + siteName, + includedPaths, + excludedPaths, }); + const sitemap = await service.fetchSSGSitemap(['en']); - const service = new GraphQLSitemapService({ - endpoint, - apiKey, - sites: multipleSites, - }); - const sitemap = await service.fetchSSGSitemap([lang]); - - expect(sitemap).to.deep.equal([ - { - params: { - path: ['_site_site1'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site1', 'x1'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site1', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site1', 'y1', 'y2'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site2', 'y1'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site2', 'x1', 'x2'], - }, - locale: lang, - }, - ]); - return expect(nock.isDone()).to.be.true; - }); - - it('should return aggregated paths for multiple sites and personalized sites', async () => { - const multipleSites = ['site1', 'site2']; - const lang = 'ua'; - - nock(endpoint) - .post('/', /PersonalizeSitemapQuery/gi) - .reply(200, sitemapPersonalizeQueryResult); - - nock(endpoint) - .persist() - .post('/', (body) => { - return body.variables.siteName === multipleSites[1]; - }) - .reply(200, { - data: { - site: { - siteInfo: { - routes: { - total: 4, - pageInfo: { - hasNext: false, - }, - results: [ - { - path: '/', - }, - { - path: '/x1', - }, - { - path: '/y1/y2/y3/y4', - }, - { - path: '/y1/y2', - }, - ], - }, - }, + return expect(sitemap).to.deep.equal([ + { + params: { + path: ['y1', 'y2'], }, + locale: 'en', }, - }); - - const service = new GraphQLSitemapService({ - endpoint, - apiKey, - sites: multipleSites, - includePersonalizedRoutes: true, + ]); }); - const sitemap = await service.fetchSSGSitemap([lang]); - console.log(sitemap.map((item) => item.params.path)); - expect(sitemap).to.deep.equal([ - { - params: { - path: ['_site_site1'], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_green', '_site_site1'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site1', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_green', '_site_site1', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_red', '_site_site1', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_variantId_purple', '_site_site1', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site2'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site2', 'x1'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site2', 'y1', 'y2', 'y3', 'y4'], - }, - locale: lang, - }, - { - params: { - path: ['_site_site2', 'y1', 'y2'], - }, - locale: lang, - }, - ]); - return expect(nock.isDone()).to.be.true; - }); + it('should return personalized paths when personalize data is requested and returned for single site', async () => { + const lang = 'ua'; - it('should work when multiple languages are requested', async () => { - const lang1 = 'ua'; - const lang2 = 'da-DK'; + nock(endpoint) + .post('/', /PersonalizeSitemapQuery/gi) + .reply(200, sitemapPersonalizeQueryResult); - nock(endpoint) - .post('/', (body) => { - return body.variables.language === lang1; - }) - .reply(200, { - data: { - site: { - siteInfo: { - routes: { - total: 4, - pageInfo: { - hasNext: false, - }, - results: [ - { - path: '/', - }, - { - path: '/x1', - }, - { - path: '/y1/y2/y3/y4', - }, - { - path: '/y1/y2', - }, - ], - }, - }, - }, - }, + const service = new GraphQLSitemapService({ + endpoint, + apiKey, + siteName, + includePersonalizedRoutes: true, }); + const sitemap = await service.fetchSSGSitemap([lang]); - nock(endpoint) - .post('/', (body) => { - return body.variables.language === lang2; - }) - .reply(200, { - data: { - site: { - siteInfo: { - routes: { - total: 4, - pageInfo: { - hasNext: false, - }, - results: [ - { - path: '/', - }, - { - path: '/x1-da-DK', - }, - { - path: '/y1/y2/y3/y4-da-DK', - }, - { - path: '/y1/y2-da-DK', - }, - ], - }, - }, + expect(sitemap).to.deep.equal([ + { + params: { + path: [''], }, + locale: lang, }, - }); - - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); - const sitemap = await service.fetchSSGSitemap([lang1, lang2]); - - expect(sitemap).to.deep.equal([ - { - params: { - path: [''], - }, - locale: 'ua', - }, - { - params: { - path: ['x1'], - }, - locale: 'ua', - }, - { - params: { - path: ['y1', 'y2', 'y3', 'y4'], - }, - locale: 'ua', - }, - { - params: { - path: ['y1', 'y2'], + { + params: { + path: ['_variantId_green'], + }, + locale: lang, }, - locale: 'ua', - }, - { - params: { - path: [''], + { + params: { + path: ['y1', 'y2', 'y3', 'y4'], + }, + locale: lang, }, - locale: 'da-DK', - }, - { - params: { - path: ['x1-da-DK'], + { + params: { + path: ['_variantId_green', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, }, - locale: 'da-DK', - }, - { - params: { - path: ['y1', 'y2', 'y3', 'y4-da-DK'], + { + params: { + path: ['_variantId_red', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, }, - locale: 'da-DK', - }, - { - params: { - path: ['y1', 'y2-da-DK'], + { + params: { + path: ['_variantId_purple', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, }, - locale: 'da-DK', - }, - ]); - - return expect(nock.isDone()).to.be.true; - }); - - it('should work when null results are present', async () => { - const lang = 'en'; + ]); + return expect(nock.isDone()).to.be.true; + }); - nock(endpoint) - .post('/', (body) => { - return body.variables.language === lang; - }) - .reply(200, { - data: { - site: { - siteInfo: { - routes: { - total: 4, - pageInfo: { - hasNext: false, - }, - results: [ - { - path: '/', - }, - { - path: '/x1', + it('should work when null results are present', async () => { + const lang = 'en'; + + nock(endpoint) + .post('/', (body) => { + return body.variables.language === lang; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, }, - null, - null, - ], + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + null, + null, + ], + }, }, }, }, - }, - }); + }); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); - const sitemap = await service.fetchSSGSitemap([lang]); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemap = await service.fetchSSGSitemap([lang]); - expect(sitemap).to.deep.equal([ - { - params: { - path: [''], + expect(sitemap).to.deep.equal([ + { + params: { + path: [''], + }, + locale: 'en', }, - locale: 'en', - }, - { - params: { - path: ['x1'], + { + params: { + path: ['x1'], + }, + locale: 'en', }, - locale: 'en', - }, - ]); + ]); - return expect(nock.isDone()).to.be.true; - }); + return expect(nock.isDone()).to.be.true; + }); - it('should throw error if valid language is not provided', async () => { - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); - await service.fetchSSGSitemap([]).catch((error: RangeError) => { - expect(error.message).to.equal(languageError); + it('should throw error if valid language is not provided', async () => { + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + await service.fetchSSGSitemap([]).catch((error: RangeError) => { + expect(error.message).to.equal(languageError); + }); }); - }); - it('should throw error if query returns nothing for a provided site name', async () => { - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); - nock(endpoint) - .post('/', (body) => { - return body.variables.siteName === sites[0]; - }) - .reply(200, { - data: { - site: { - siteInfo: null, + it('should throw error if query returns nothing for a provided site name', async () => { + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + nock(endpoint) + .post('/', (body) => { + return body.variables.siteName === siteName; + }) + .reply(200, { + data: { + site: { + siteInfo: null, + }, }, - }, + }); + await service.fetchSSGSitemap(['en']).catch((error: RangeError) => { + expect(error.message).to.equal(getSiteEmptyError(siteName)); }); - await service.fetchSSGSitemap(['en']).catch((error: RangeError) => { - expect(error.message).to.equal(getSiteEmptyError(sites[0])); }); - }); - it('should throw error if empty language is provided', async () => { - mockPathsRequest(); + it('should throw error if empty language is provided', async () => { + mockPathsRequest(); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); - await service.fetchExportSitemap('').catch((error: RangeError) => { - expect(error.message).to.equal('The language must be a non-empty string'); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + await service.fetchExportSitemap('').catch((error: RangeError) => { + expect(error.message).to.equal('The language must be a non-empty string'); + }); + + return expect(nock.isDone()).to.be.false; }); - return expect(nock.isDone()).to.be.false; - }); + it('should use a custom pageSize, if provided', async () => { + const customPageSize = 20; - it('should use a custom pageSize, if provided', async () => { - const customPageSize = 20; + nock(endpoint) + .post('/', (body) => body.variables.pageSize === customPageSize) + .reply(200, sitemapDefaultQueryResult); - nock(endpoint) - .post('/', (body) => body.variables.pageSize === customPageSize) - .reply(200, sitemapDefaultQueryResult); + const service = new GraphQLSitemapService({ + endpoint, + apiKey, + siteName, + pageSize: customPageSize, + }); + const sitemap = await service.fetchSSGSitemap(['ua']); - const service = new GraphQLSitemapService({ - endpoint, - apiKey, - sites, - pageSize: customPageSize, + expect(sitemap).to.deep.equal(sitemapServiceSinglesiteResult); + return expect(nock.isDone()).to.be.true; }); - const sitemap = await service.fetchSSGSitemap(['ua']); - - expect(sitemap).to.deep.equal(sitemapServiceResult); - return expect(nock.isDone()).to.be.true; - }); - it('should use default value if pageSize is not specified', async () => { - nock(endpoint) - .post( - '/', - (body) => - body.query.indexOf('$pageSize: Int = 10') > 0 && body.variables.pageSize === undefined - ) - .reply(200, sitemapDefaultQueryResult); + it('should use default value if pageSize is not specified', async () => { + nock(endpoint) + .post( + '/', + (body) => + body.query.indexOf('$pageSize: Int = 10') > 0 && body.variables.pageSize === undefined + ) + .reply(200, sitemapDefaultQueryResult); + + const service = new GraphQLSitemapService({ + endpoint, + apiKey, + siteName, + pageSize: undefined, + }); + const sitemap = await service.fetchSSGSitemap(['ua']); - const service = new GraphQLSitemapService({ - endpoint, - apiKey, - sites, - pageSize: undefined, + expect(sitemap).to.deep.equal(sitemapServiceSinglesiteResult); + return expect(nock.isDone()).to.be.true; }); - const sitemap = await service.fetchSSGSitemap(['ua']); - expect(sitemap).to.deep.equal(sitemapServiceResult); - return expect(nock.isDone()).to.be.true; - }); - - it('should work if sitemap has 0 pages', async () => { - mockPathsRequest([]); + it('should work if sitemap has 0 pages', async () => { + mockPathsRequest([]); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); - const sitemap = await service.fetchSSGSitemap(['ua']); - expect(sitemap).to.deep.equal([]); - return expect(nock.isDone()).to.be.true; - }); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemap = await service.fetchSSGSitemap(['ua']); + expect(sitemap).to.deep.equal([]); + return expect(nock.isDone()).to.be.true; + }); - it('should throw error if SitemapQuery fails', async () => { - nock(endpoint) - .post('/', /DefaultSitemapQuery/gi) - .reply(500, 'Error 😥'); + it('should throw error if SitemapQuery fails', async () => { + nock(endpoint) + .post('/', /DefaultSitemapQuery/gi) + .reply(500, 'Error 😥'); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); - await service.fetchSSGSitemap(['ua']).catch((error: RangeError) => { - expect(error.message).to.contain('SitemapQuery'); - expect(error.message).to.contain('Error 😥'); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + await service.fetchSSGSitemap(['ua']).catch((error: RangeError) => { + expect(error.message).to.contain('SitemapQuery'); + expect(error.message).to.contain('Error 😥'); + }); + return expect(nock.isDone()).to.be.true; }); - return expect(nock.isDone()).to.be.true; }); }); - const expectedExportSitemap = [ + const expectedSinglesiteExportSitemap = [ { params: { path: [''], @@ -760,16 +503,16 @@ describe('GraphQLSitemapService', () => { ]; describe('Fetch sitemap in export mode', () => { - it('should fetch sitemap', async () => { + it('should fetch singlesite sitemap', async () => { mockPathsRequest(); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); const sitemap = await service.fetchExportSitemap('ua'); - expect(sitemap).to.deep.equal(expectedExportSitemap); + expect(sitemap).to.deep.equal(expectedSinglesiteExportSitemap); return expect(nock.isDone()).to.be.true; }); it('should work if endpoint returns 0 pages', async () => { mockPathsRequest([]); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); const sitemap = await service.fetchExportSitemap('ua'); expect(sitemap).to.deep.equal([]); return expect(nock.isDone()).to.be.true; @@ -778,7 +521,7 @@ describe('GraphQLSitemapService', () => { nock(endpoint) .post('/', /DefaultSitemapQuery/gi) .reply(500, 'Error 😥'); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); await service.fetchExportSitemap('ua').catch((error: RangeError) => { expect(error.message).to.contain('SitemapQuery'); expect(error.message).to.contain('Error 😥'); @@ -787,7 +530,7 @@ describe('GraphQLSitemapService', () => { }); it('should throw error if language is not provided', async () => { mockPathsRequest(); - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); await service.fetchExportSitemap('').catch((error: RangeError) => { expect(error.message).to.equal('The language must be a non-empty string'); }); @@ -795,10 +538,10 @@ describe('GraphQLSitemapService', () => { }); }); it('should throw error if query returns nothing for a provided site name', async () => { - const service = new GraphQLSitemapService({ endpoint, apiKey, sites }); + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); nock(endpoint) .post('/', (body) => { - return body.variables.siteName === sites[0]; + return body.variables.siteName === siteName; }) .reply(200, { data: { @@ -808,14 +551,14 @@ describe('GraphQLSitemapService', () => { }, }); await service.fetchExportSitemap('en').catch((error: RangeError) => { - expect(error.message).to.equal(getSiteEmptyError(sites[0])); + expect(error.message).to.equal(getSiteEmptyError(siteName)); }); }); it('should provide a default GraphQL client', () => { const service = new TestService({ endpoint, apiKey, - sites, + siteName, }); const graphQLClient = service.client as GraphQLClient; const graphQLRequestClient = service.client as GraphQLRequestClient; diff --git a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts index 3a7d18e164..bcd2402975 100644 --- a/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts +++ b/packages/sitecore-jss-nextjs/src/services/graphql-sitemap-service.ts @@ -1,11 +1,11 @@ -import { GraphQLClient, GraphQLRequestClient, PageInfo } from '@sitecore-jss/sitecore-jss/graphql'; -import { debug } from '@sitecore-jss/sitecore-jss'; -import { getPersonalizedRewrite } from '@sitecore-jss/sitecore-jss/personalize'; -import { getSiteRewrite } from '@sitecore-jss/sitecore-jss/site'; +import { + BaseGraphQLSitemapService, + BaseGraphQLSitemapServiceConfig, +} from './base-graphql-sitemap-service'; /** @private */ export const languageError = 'The list of languages cannot be empty'; -export const sitesError = 'The list of sites cannot be empty'; +export const siteError = 'The service needs a site name'; /** * @param {string} siteName to inject into error text @@ -15,138 +15,14 @@ export function getSiteEmptyError(siteName: string) { return `Site "${siteName}" does not exist or site item tree is missing`; } -const languageEmptyError = 'The language must be a non-empty string'; - -/** - * GQL query made dynamic based on schema differences between SXP and XM Cloud - * @param {boolean} usesPersonalize flag to detrmine which variation of a query to run - * @returns GraphQL query to fetch site paths with - */ -const defaultQuery = (usesPersonalize?: boolean) => /* GraphQL */ ` -query ${usesPersonalize ? 'PersonalizeSitemapQuery' : 'DefaultSitemapQuery'}( - $siteName: String! - $language: String! - $includedPaths: [String] - $excludedPaths: [String] - $pageSize: Int = 10 - $after: String -) { - site { - siteInfo(site: $siteName) { - routes( - language: $language - includedPaths: $includedPaths - excludedPaths: $excludedPaths - first: $pageSize - after: $after - ){ - total - pageInfo { - endCursor - hasNext - } - results { - path: routePath - ${ - usesPersonalize - ? ` - route { - personalization { - variantIds - } - }` - : '' - } - } - } - } - } -} -`; -/** - * type for input variables for the site routes query - */ -interface SiteRouteQueryVariables { - /** - * Required. The name of the site being queried. - */ - siteName: string; - /** - * Required. The language to return routes/pages for. - */ - language: string; - /** - * Optional. Only paths starting with these provided prefixes will be returned. - */ - includedPaths?: string[]; - /** - * Optional. Paths starting with these provided prefixes will be excluded from returned results. - */ - excludedPaths?: string[]; - - /** common variable for all GraphQL queries - * it will be used for every type of query to regulate result batch size - * Optional. How many result items to fetch in each GraphQL call. This is needed for pagination. - * @default 10 - */ - pageSize?: number; -} - -/** - * Schema of data returned in response to a "site" query request - * @template T The type of objects being requested. - */ -export interface SiteRouteQueryResult { - site: { - siteInfo: { - routes: { - /** - * Data needed to paginate the site results - */ - pageInfo: PageInfo; - results: T[]; - }; - }; - }; -} - -/** - * The schema of data returned in response to a routes list query request - */ -export type RouteListQueryResult = { - path: string; - route?: { - personalization?: { - variantIds: string[]; - }; - }; -}; - /** * Configuration options for @see GraphQLSitemapService instances */ -export interface GraphQLSitemapServiceConfig - extends Omit { +export interface GraphQLSitemapServiceConfig extends BaseGraphQLSitemapServiceConfig { /** - * Your Graphql endpoint + * Name of the site to retrieve site paths for */ - endpoint: string; - - /** - * The API key to use for authentication. - */ - apiKey: string; - - /** - * Names of the configured sites - */ - sites: string[]; - - /** - * A flag for whether to include personalized routes in service output - only works on XM Cloud - * turned off by default - */ - includePersonalizedRoutes?: boolean; + siteName: string; } /** @@ -161,58 +37,17 @@ export type StaticPath = { /** * Service that fetches the list of site pages using Sitecore's GraphQL API. + * Used to handle a single site * This list is used for SSG and Export functionality. * @mixes SearchQueryService */ -export class GraphQLSitemapService { - private graphQLClient: GraphQLClient; - - /** - * Gets the default query used for fetching the list of site pages - */ - protected get query(): string { - return defaultQuery(this.options.includePersonalizedRoutes); - } - +export class GraphQLSitemapService extends BaseGraphQLSitemapService { /** * Creates an instance of graphQL sitemap service with the provided options * @param {GraphQLSitemapServiceConfig} options instance */ constructor(public options: GraphQLSitemapServiceConfig) { - this.graphQLClient = this.getGraphQLClient(); - } - - /** - * Fetch sitemap which could be used for generation of static pages during `next export`. - * The `locale` parameter will be used in the item query, but since i18n is not supported, - * the output paths will not include a `language` property. - * @param {string} locale which application supports - * @returns an array of @see StaticPath objects - */ - async fetchExportSitemap(locale: string): Promise { - const formatPath = (path: string[]) => ({ - params: { - path, - }, - }); - - return this.fetchSitemap([locale], formatPath); - } - - /** - * Fetch sitemap which could be used for generation of static pages using SSG mode - * @param {string[]} locales locales which application supports - * @returns an array of @see StaticPath objects - */ - async fetchSSGSitemap(locales: string[]): Promise { - const formatPath = (path: string[], locale: string) => ({ - params: { - path, - }, - locale, - }); - - return this.fetchSitemap(locales, formatPath); + super(options); } /** @@ -233,127 +68,14 @@ export class GraphQLSitemapService { throw new RangeError(languageError); } - // Get all sites - const sites = this.options.sites; - if (!sites || !sites.length) { - throw new RangeError(sitesError); - } - - // Fetch paths for each site - for (let i = 0; i < sites.length; i++) { - const siteName = sites[i]; - const multiSiteName = sites.length > 1 ? siteName : undefined; + const siteName = this.options.siteName; - // Fetch paths using all locales - await Promise.all( - languages.map(async (language) => { - if (language === '') { - throw new RangeError(languageEmptyError); - } - debug.sitemap('fetching sitemap data for %s %s', language, siteName); - const results = await this.fetchLanguageSitePaths(language, siteName); - const transformedPaths = await this.transformLanguageSitePaths( - results, - formatStaticPath, - language, - multiSiteName - ); - paths.push(...transformedPaths); - }) - ); + if (!siteName) { + throw new RangeError(siteError); } - return ([] as StaticPath[]).concat(...paths); - } - - protected async transformLanguageSitePaths( - sitePaths: RouteListQueryResult[], - formatStaticPath: (path: string[], language: string) => StaticPath, - language: string, - multiSiteName?: string - ): Promise { - const formatPath = (path: string) => - formatStaticPath(path.replace(/^\/|\/$/g, '').split('/'), language); - - const aggregatedPaths: StaticPath[] = []; - - sitePaths.forEach((item) => { - if (!item) return; - - if (!multiSiteName) { - aggregatedPaths.push(formatPath(item.path)); - } else { - aggregatedPaths.push(formatPath(getSiteRewrite(item.path, { siteName: multiSiteName }))); - } - - // check for type safety's sake - personalize may be empty depending on query type - if (item.route?.personalization?.variantIds.length) { - multiSiteName - ? aggregatedPaths.push( - ...(item.route?.personalization?.variantIds.map((varId) => - formatPath( - getPersonalizedRewrite(getSiteRewrite(item.path, { siteName: multiSiteName }), { - variantId: varId, - }) - ) - ) || {}) - ) - : aggregatedPaths.push( - ...(item.route?.personalization?.variantIds.map((varId) => - formatPath(getPersonalizedRewrite(item.path, { variantId: varId })) - ) || {}) - ); - } - }); - - return aggregatedPaths; - } + paths.push(...(await this.getTranformedPaths(siteName, languages, formatStaticPath))); - protected async fetchLanguageSitePaths( - language: string, - siteName: string - ): Promise { - const args: SiteRouteQueryVariables = { - siteName: siteName, - language: language, - pageSize: this.options.pageSize, - includedPaths: this.options.includedPaths, - excludedPaths: this.options.excludedPaths, - }; - let results: RouteListQueryResult[] = []; - let hasNext = true; - let after = ''; - - while (hasNext) { - const fetchResponse = await this.graphQLClient.request< - SiteRouteQueryResult - >(this.query, { - ...args, - after, - }); - - if (!fetchResponse?.site?.siteInfo) { - throw new RangeError(getSiteEmptyError(siteName)); - } else { - results = results.concat(fetchResponse.site.siteInfo.routes?.results); - hasNext = fetchResponse.site.siteInfo.routes?.pageInfo.hasNext; - after = fetchResponse.site.siteInfo.routes?.pageInfo.endCursor; - } - } - - return results; - } - - /** - * Gets a GraphQL client that can make requests to the API. Uses graphql-request as the default - * library for fetching graphql data (@see GraphQLRequestClient). Override this method if you - * want to use something else. - * @returns {GraphQLClient} implementation - */ - protected getGraphQLClient(): GraphQLClient { - return new GraphQLRequestClient(this.options.endpoint, { - apiKey: this.options.apiKey, - debugger: debug.sitemap, - }); + return ([] as StaticPath[]).concat(...paths); } } diff --git a/packages/sitecore-jss-nextjs/src/services/mutisite-graphql-sitemap-service.test.ts b/packages/sitecore-jss-nextjs/src/services/mutisite-graphql-sitemap-service.test.ts new file mode 100644 index 0000000000..b62213b607 --- /dev/null +++ b/packages/sitecore-jss-nextjs/src/services/mutisite-graphql-sitemap-service.test.ts @@ -0,0 +1,830 @@ +import { expect } from 'chai'; +import nock from 'nock'; +import { + MultisiteGraphQLSitemapService, + MultisiteGraphQLSitemapServiceConfig, +} from './mutisite-graphql-sitemap-service'; +import { getSiteEmptyError, languageError } from './graphql-sitemap-service'; +import sitemapDefaultQueryResult from '../test-data/sitemapDefaultQueryResult.json'; +import sitemapPersonalizeQueryResult from '../test-data/sitemapPersonalizeQueryResult.json'; +import sitemapServiceMultisiteResult from '../test-data/sitemapServiceMultisiteResult'; +import { GraphQLClient, GraphQLRequestClient } from '@sitecore-jss/sitecore-jss/graphql'; + +class TestService extends MultisiteGraphQLSitemapService { + public client: GraphQLClient; + constructor(options: MultisiteGraphQLSitemapServiceConfig) { + super(options); + this.client = this.getGraphQLClient(); + } +} + +describe('MultisiteGraphQLSitemapService', () => { + const endpoint = 'http://site'; + const apiKey = 'some-api-key'; + const sites = ['site-name']; + + afterEach(() => { + nock.cleanAll(); + }); + + const mockPathsRequest = (results?: { url: { path: string } }[]) => { + nock(endpoint) + .post('/', /DefaultSitemapQuery/gi) + .reply( + 200, + results === undefined + ? sitemapDefaultQueryResult + : { + data: { + site: { + siteInfo: { + routes: { + total: results.length, + pageInfo: { + hasNext: false, + }, + results, + }, + }, + }, + }, + } + ); + }; + + describe('Fetch sitemap in SSG mode', () => { + it('should work when 1 language is requested', async () => { + mockPathsRequest(); + + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + const sitemap = await service.fetchSSGSitemap(['ua']); + expect(sitemap).to.deep.equal(sitemapServiceMultisiteResult); + + return expect(nock.isDone()).to.be.true; + }); + + it('should work when includePaths and excludePaths are provided', async () => { + const includedPaths = ['/y1/']; + const excludedPaths = ['/y1/y2/y3/y4']; + + nock(endpoint) + .post('/', (body) => { + return body.variables.includedPaths && body.variables.excludedPaths; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 1, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/y1/y2/', + }, + ], + }, + }, + }, + }, + }); + + nock(endpoint) + .post('/') + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, + }, + }, + }, + }); + + const service = new MultisiteGraphQLSitemapService({ + endpoint, + apiKey, + sites, + includedPaths, + excludedPaths, + }); + const sitemap = await service.fetchSSGSitemap(['en']); + + return expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site-name', 'y1', 'y2'], + }, + locale: 'en', + }, + ]); + }); + + describe('Fetch sitemap in SSG mode', () => { + it('should work when 1 language is requested', async () => { + mockPathsRequest(); + + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + const sitemap = await service.fetchSSGSitemap(['ua']); + expect(sitemap).to.deep.equal(sitemapServiceMultisiteResult); + + return expect(nock.isDone()).to.be.true; + }); + + it('should work when includePaths and excludePaths are provided', async () => { + const includedPaths = ['/y1/']; + const excludedPaths = ['/y1/y2/y3/y4']; + + nock(endpoint) + .post('/', (body) => { + return body.variables.includedPaths && body.variables.excludedPaths; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 1, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/y1/y2/', + }, + ], + }, + }, + }, + }, + }); + + nock(endpoint) + .post('/') + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, + }, + }, + }, + }); + + const service = new MultisiteGraphQLSitemapService({ + endpoint, + apiKey, + sites, + includedPaths, + excludedPaths, + }); + const sitemap = await service.fetchSSGSitemap(['en']); + + return expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site-name', 'y1', 'y2'], + }, + locale: 'en', + }, + ]); + }); + + it('should return aggregated paths for multiple sites with no personalization', async () => { + const multipleSites = ['site1', 'site2']; + const lang = 'ua'; + + nock(endpoint) + .persist() + .post('/', (body) => { + return body.variables.siteName === multipleSites[0]; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, + }, + }, + }, + }); + + nock(endpoint) + .persist() + .post('/', (body) => { + return body.variables.siteName === multipleSites[1]; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 2, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/y1', + }, + { + path: '/x1/x2', + }, + ], + }, + }, + }, + }, + }); + + const service = new MultisiteGraphQLSitemapService({ + endpoint, + apiKey, + sites: multipleSites, + }); + const sitemap = await service.fetchSSGSitemap([lang]); + + expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'x1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'y1', 'y2'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'y1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'x1', 'x2'], + }, + locale: lang, + }, + ]); + return expect(nock.isDone()).to.be.true; + }); + + it('should return aggregated paths for multiple sites and personalized sites', async () => { + const multipleSites = ['site1', 'site2']; + const lang = 'ua'; + + nock(endpoint) + .post('/', /PersonalizeSitemapQuery/gi) + .reply(200, sitemapPersonalizeQueryResult); + + nock(endpoint) + .persist() + .post('/', (body) => { + return body.variables.siteName === multipleSites[1]; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, + }, + }, + }, + }); + + const service = new MultisiteGraphQLSitemapService({ + endpoint, + apiKey, + sites: multipleSites, + includePersonalizedRoutes: true, + }); + const sitemap = await service.fetchSSGSitemap([lang]); + + expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site1'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_green', '_site_site1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_green', '_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_red', '_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_variantId_purple', '_site_site1', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'x1'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'y1', 'y2', 'y3', 'y4'], + }, + locale: lang, + }, + { + params: { + path: ['_site_site2', 'y1', 'y2'], + }, + locale: lang, + }, + ]); + return expect(nock.isDone()).to.be.true; + }); + + it('should work when multiple languages are requested', async () => { + const lang1 = 'ua'; + const lang2 = 'da-DK'; + + nock(endpoint) + .post('/', (body) => { + return body.variables.language === lang1; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + { + path: '/y1/y2/y3/y4', + }, + { + path: '/y1/y2', + }, + ], + }, + }, + }, + }, + }); + + nock(endpoint) + .post('/', (body) => { + return body.variables.language === lang2; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1-da-DK', + }, + { + path: '/y1/y2/y3/y4-da-DK', + }, + { + path: '/y1/y2-da-DK', + }, + ], + }, + }, + }, + }, + }); + + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + const sitemap = await service.fetchSSGSitemap([lang1, lang2]); + + expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site-name'], + }, + locale: 'ua', + }, + { + params: { + path: ['_site_site-name', 'x1'], + }, + locale: 'ua', + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2', 'y3', 'y4'], + }, + locale: 'ua', + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2'], + }, + locale: 'ua', + }, + { + params: { + path: ['_site_site-name'], + }, + locale: 'da-DK', + }, + { + params: { + path: ['_site_site-name', 'x1-da-DK'], + }, + locale: 'da-DK', + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2', 'y3', 'y4-da-DK'], + }, + locale: 'da-DK', + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2-da-DK'], + }, + locale: 'da-DK', + }, + ]); + + return expect(nock.isDone()).to.be.true; + }); + + it('should work when null results are present', async () => { + const lang = 'en'; + + nock(endpoint) + .post('/', (body) => { + return body.variables.language === lang; + }) + .reply(200, { + data: { + site: { + siteInfo: { + routes: { + total: 4, + pageInfo: { + hasNext: false, + }, + results: [ + { + path: '/', + }, + { + path: '/x1', + }, + null, + null, + ], + }, + }, + }, + }, + }); + + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + const sitemap = await service.fetchSSGSitemap([lang]); + + expect(sitemap).to.deep.equal([ + { + params: { + path: ['_site_site-name'], + }, + locale: 'en', + }, + { + params: { + path: ['_site_site-name', 'x1'], + }, + locale: 'en', + }, + ]); + + return expect(nock.isDone()).to.be.true; + }); + + it('should throw error if valid language is not provided', async () => { + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + await service.fetchSSGSitemap([]).catch((error: RangeError) => { + expect(error.message).to.equal(languageError); + }); + }); + + it('should throw error if query returns nothing for a provided site name', async () => { + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + nock(endpoint) + .post('/', (body) => { + return body.variables.siteName === sites[0]; + }) + .reply(200, { + data: { + site: { + siteInfo: null, + }, + }, + }); + await service.fetchSSGSitemap(['en']).catch((error: RangeError) => { + expect(error.message).to.equal(getSiteEmptyError(sites[0])); + }); + }); + + it('should throw error if empty language is provided', async () => { + mockPathsRequest(); + + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + await service.fetchExportSitemap('').catch((error: RangeError) => { + expect(error.message).to.equal('The language must be a non-empty string'); + }); + + return expect(nock.isDone()).to.be.false; + }); + + it('should use a custom pageSize, if provided', async () => { + const customPageSize = 20; + + nock(endpoint) + .post('/', (body) => body.variables.pageSize === customPageSize) + .reply(200, sitemapDefaultQueryResult); + + const service = new MultisiteGraphQLSitemapService({ + endpoint, + apiKey, + sites, + pageSize: customPageSize, + }); + const sitemap = await service.fetchSSGSitemap(['ua']); + + expect(sitemap).to.deep.equal(sitemapServiceMultisiteResult); + return expect(nock.isDone()).to.be.true; + }); + + it('should use default value if pageSize is not specified', async () => { + nock(endpoint) + .post( + '/', + (body) => + body.query.indexOf('$pageSize: Int = 10') > 0 && body.variables.pageSize === undefined + ) + .reply(200, sitemapDefaultQueryResult); + + const service = new MultisiteGraphQLSitemapService({ + endpoint, + apiKey, + sites, + pageSize: undefined, + }); + const sitemap = await service.fetchSSGSitemap(['ua']); + + expect(sitemap).to.deep.equal(sitemapServiceMultisiteResult); + return expect(nock.isDone()).to.be.true; + }); + + it('should work if sitemap has 0 pages', async () => { + mockPathsRequest([]); + + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + const sitemap = await service.fetchSSGSitemap(['ua']); + expect(sitemap).to.deep.equal([]); + return expect(nock.isDone()).to.be.true; + }); + + it('should throw error if SitemapQuery fails', async () => { + nock(endpoint) + .post('/', /DefaultSitemapQuery/gi) + .reply(500, 'Error 😥'); + + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + await service.fetchSSGSitemap(['ua']).catch((error: RangeError) => { + expect(error.message).to.contain('SitemapQuery'); + expect(error.message).to.contain('Error 😥'); + }); + return expect(nock.isDone()).to.be.true; + }); + }); + }); + + const expectedMultisiteExportSitemap = [ + { + params: { + path: ['_site_site-name'], + }, + }, + { + params: { + path: ['_site_site-name', 'x1'], + }, + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2', 'y3', 'y4'], + }, + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2'], + }, + }, + ]; + + describe('Fetch sitemap in export mode', () => { + it('should fetch multisite sitemap', async () => { + mockPathsRequest(); + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + const sitemap = await service.fetchExportSitemap('ua'); + expect(sitemap).to.deep.equal(expectedMultisiteExportSitemap); + return expect(nock.isDone()).to.be.true; + }); + it('should work if endpoint returns 0 pages', async () => { + mockPathsRequest([]); + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + const sitemap = await service.fetchExportSitemap('ua'); + expect(sitemap).to.deep.equal([]); + return expect(nock.isDone()).to.be.true; + }); + it('should throw error if SitemapQuery fails', async () => { + nock(endpoint) + .post('/', /DefaultSitemapQuery/gi) + .reply(500, 'Error 😥'); + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + await service.fetchExportSitemap('ua').catch((error: RangeError) => { + expect(error.message).to.contain('SitemapQuery'); + expect(error.message).to.contain('Error 😥'); + }); + return expect(nock.isDone()).to.be.true; + }); + it('should throw error if language is not provided', async () => { + mockPathsRequest(); + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + await service.fetchExportSitemap('').catch((error: RangeError) => { + expect(error.message).to.equal('The language must be a non-empty string'); + }); + return expect(nock.isDone()).to.be.false; + }); + }); + it('should throw error if query returns nothing for a provided site name', async () => { + const service = new MultisiteGraphQLSitemapService({ endpoint, apiKey, sites }); + nock(endpoint) + .post('/', (body) => { + return body.variables.siteName === sites[0]; + }) + .reply(200, { + data: { + site: { + siteInfo: null, + }, + }, + }); + await service.fetchExportSitemap('en').catch((error: RangeError) => { + expect(error.message).to.equal(getSiteEmptyError(sites[0])); + }); + }); + it('should provide a default GraphQL client', () => { + const service = new TestService({ + endpoint, + apiKey, + sites, + }); + const graphQLClient = service.client as GraphQLClient; + const graphQLRequestClient = service.client as GraphQLRequestClient; + // eslint-disable-next-line no-unused-expressions + expect(graphQLClient).to.exist; + // eslint-disable-next-line no-unused-expressions + expect(graphQLRequestClient).to.exist; + }); +}); diff --git a/packages/sitecore-jss-nextjs/src/services/mutisite-graphql-sitemap-service.ts b/packages/sitecore-jss-nextjs/src/services/mutisite-graphql-sitemap-service.ts new file mode 100644 index 0000000000..5f23d304ac --- /dev/null +++ b/packages/sitecore-jss-nextjs/src/services/mutisite-graphql-sitemap-service.ts @@ -0,0 +1,91 @@ +import { getSiteRewrite } from '@sitecore-jss/sitecore-jss/site'; +import { + BaseGraphQLSitemapService, + BaseGraphQLSitemapServiceConfig, + languageError, + RouteListQueryResult, + StaticPath, +} from './base-graphql-sitemap-service'; + +export const sitesError = 'The list of sites cannot be empty'; + +/** + * Configuration options for @see GraphQLSitemapService instances + */ +export interface MultisiteGraphQLSitemapServiceConfig extends BaseGraphQLSitemapServiceConfig { + /** + * Names of the configured sites + */ + sites: string[]; +} + +/** + * Service that fetches the list of site pages using Sitecore's GraphQL API. + * Used to handle multiple sites + * This list is used for SSG and Export functionality. + * @mixes SearchQueryService + */ +export class MultisiteGraphQLSitemapService extends BaseGraphQLSitemapService { + /** + * Creates an instance of graphQL sitemap service with the provided options + * @param {MultisiteGraphQLSitemapServiceConfig} options instance + */ + constructor(public options: MultisiteGraphQLSitemapServiceConfig) { + super(options); + } + + /** + * Fetch a flat list of all pages that belong to all the requested sites and have a + * version in the specified language(s). + * @param {string[]} languages Fetch pages that have versions in this language(s). + * @param {Function} formatStaticPath Function for transforming the raw search results into (@see StaticPath) types. + * @returns list of pages + * @throws {RangeError} if the list of languages is empty. + * @throws {RangeError} if the any of the languages is an empty string. + */ + protected async fetchSitemap( + languages: string[], + formatStaticPath: (path: string[], language: string) => StaticPath + ): Promise { + const paths = new Array(); + if (!languages.length) { + throw new RangeError(languageError); + } + // Get all sites + const sites = this.options.sites; + if (!sites || !sites.length) { + throw new RangeError(sitesError); + } + + // Fetch paths for each site + for (let i = 0; i < sites.length; i++) { + const siteName = sites[i]; + + // Fetch paths using all locales + paths.push(...(await this.getTranformedPaths(siteName, languages, formatStaticPath))); + } + + return ([] as StaticPath[]).concat(...paths); + } + + /** + * Fetch and return site paths for multisite implementation, with prefixes included + * @param {string} language path language + * @param {string} siteName site name + * @returns modified paths + */ + protected async fetchLanguageSitePaths( + language: string, + siteName: string + ): Promise { + const results: RouteListQueryResult[] = await super.fetchLanguageSitePaths(language, siteName); + + results.forEach((item) => { + if (item) { + item.path = getSiteRewrite(item.path, { siteName: siteName }); + } + }); + + return results; + } +} diff --git a/packages/sitecore-jss-nextjs/src/test-data/sitemapServiceMultisiteResult.ts b/packages/sitecore-jss-nextjs/src/test-data/sitemapServiceMultisiteResult.ts new file mode 100644 index 0000000000..f0c6d7c9ec --- /dev/null +++ b/packages/sitecore-jss-nextjs/src/test-data/sitemapServiceMultisiteResult.ts @@ -0,0 +1,26 @@ +export default [ + { + params: { + path: ['_site_site-name'], + }, + locale: 'ua', + }, + { + params: { + path: ['_site_site-name', 'x1'], + }, + locale: 'ua', + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2', 'y3', 'y4'], + }, + locale: 'ua', + }, + { + params: { + path: ['_site_site-name', 'y1', 'y2'], + }, + locale: 'ua', + }, +]; diff --git a/packages/sitecore-jss-nextjs/src/test-data/sitemapServiceResult.ts b/packages/sitecore-jss-nextjs/src/test-data/sitemapServiceSinglesiteResult.ts similarity index 100% rename from packages/sitecore-jss-nextjs/src/test-data/sitemapServiceResult.ts rename to packages/sitecore-jss-nextjs/src/test-data/sitemapServiceSinglesiteResult.ts