diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/package.json b/packages/create-sitecore-jss/src/templates/nextjs-sxa/package.json index abf099e620..7d1ac8d783 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/package.json +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/package.json @@ -2,4 +2,4 @@ "dependencies": { "bootstrap": "^5.1.3" } -} \ No newline at end of file +} diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts index 106082e5c4..9b971794db 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/middleware/plugins/redirects.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { RedirectsMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/middleware'; +import { RedirectsMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/edge'; import config from 'temp/config'; import { MiddlewarePlugin } from '..'; @@ -21,7 +21,7 @@ class RedirectsPlugin implements MiddlewarePlugin { * @returns Promise */ async exec(req: NextRequest): Promise { - return this.redirectsMiddleware.getHandler(req); + return this.redirectsMiddleware.getHandler()(req); } } diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/next-config/plugins/sitemap.js b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/next-config/plugins/sitemap.js new file mode 100644 index 0000000000..f8b8a61a18 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/lib/next-config/plugins/sitemap.js @@ -0,0 +1,16 @@ +const sitemapPlugin = (nextConfig = {}) => { + return Object.assign({}, nextConfig, { + async rewrites() { + return [ + ...await nextConfig.rewrites(), + // sitemap route + { + source: '/sitemap:id([\\w-]{0,}).xml', + destination: '/api/sitemap' + }, + ]; + }, + }); +}; + +module.exports = sitemapPlugin; diff --git a/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts new file mode 100644 index 0000000000..06c4e763d2 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-sxa/src/pages/api/sitemap.ts @@ -0,0 +1,37 @@ +import { AxiosResponse } from 'axios'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import config from 'temp/config'; +import { GraphQLSitemapService } from '@sitecore-jss/sitecore-jss/site'; +import { AxiosDataFetcher } from '@sitecore-jss/sitecore-jss'; + +const ABSOLUTE_URL_REGEXP = '^(?:[a-z]+:)?//'; + +const sitemapApi = async (req: NextApiRequest, res: NextApiResponse): Promise => { + const { query: { id } } = req; + // create sitemap graphql service + const sitemapService = new GraphQLSitemapService({ + endpoint: config.graphQLEndpoint, + apiKey: config.sitecoreApiKey, + siteName: config.jssAppName, + }); + + const sitemapPath = await sitemapService.getSitemap(id as string); + + if (sitemapPath) { + const isAbsoluteUrl = sitemapPath.match(ABSOLUTE_URL_REGEXP); + const sitemapUrl = isAbsoluteUrl ? sitemapPath : `${config.sitecoreApiHost}${sitemapPath}`; + res.setHeader('Content-Type', 'text/xml;charset=utf-8'); + + return new AxiosDataFetcher().get(sitemapUrl, { + responseType: 'stream', + }) + .then((response: AxiosResponse) => { + response.data.pipe(res); + }) + .catch(() => res.redirect('/404')); + } + + res.redirect('/404'); +}; + +export default sitemapApi; diff --git a/packages/sitecore-jss-nextjs/edge.d.ts b/packages/sitecore-jss-nextjs/edge.d.ts new file mode 100644 index 0000000000..3f19d1ee12 --- /dev/null +++ b/packages/sitecore-jss-nextjs/edge.d.ts @@ -0,0 +1 @@ +export * from './types/edge/index'; diff --git a/packages/sitecore-jss-nextjs/edge.js b/packages/sitecore-jss-nextjs/edge.js new file mode 100644 index 0000000000..71323b570e --- /dev/null +++ b/packages/sitecore-jss-nextjs/edge.js @@ -0,0 +1 @@ +module.exports = require('./dist/cjs/edge/index'); diff --git a/packages/sitecore-jss-nextjs/src/edge/index.ts b/packages/sitecore-jss-nextjs/src/edge/index.ts new file mode 100644 index 0000000000..f942b0ad9b --- /dev/null +++ b/packages/sitecore-jss-nextjs/src/edge/index.ts @@ -0,0 +1 @@ +export { RedirectsMiddleware } from './redirects-middleware'; diff --git a/packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.ts b/packages/sitecore-jss-nextjs/src/edge/redirects-middleware.ts similarity index 100% rename from packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.ts rename to packages/sitecore-jss-nextjs/src/edge/redirects-middleware.ts diff --git a/packages/sitecore-jss-nextjs/src/middleware/index.ts b/packages/sitecore-jss-nextjs/src/middleware/index.ts index 0eb6dc2879..d2d818d376 100644 --- a/packages/sitecore-jss-nextjs/src/middleware/index.ts +++ b/packages/sitecore-jss-nextjs/src/middleware/index.ts @@ -4,4 +4,3 @@ export { EditingRenderMiddleware, EditingRenderMiddlewareConfig, } from './editing-render-middleware'; -export { RedirectsMiddleware } from './redirects-middleware'; diff --git a/packages/sitecore-jss-nextjs/tsconfig.json b/packages/sitecore-jss-nextjs/tsconfig.json index 15ea504631..fa62904d45 100644 --- a/packages/sitecore-jss-nextjs/tsconfig.json +++ b/packages/sitecore-jss-nextjs/tsconfig.json @@ -30,6 +30,7 @@ "typings", "dist", "middleware.d.ts", + "edge.d.ts", "src/tests/*", "src/testData/*", "**/*.test.ts", diff --git a/packages/sitecore-jss/src/site/graphql-sitemap-service.test.ts b/packages/sitecore-jss/src/site/graphql-sitemap-service.test.ts new file mode 100644 index 0000000000..bb8dfb6b33 --- /dev/null +++ b/packages/sitecore-jss/src/site/graphql-sitemap-service.test.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import nock from 'nock'; +import { GraphQLSitemapService } from './graphql-sitemap-service'; +import { siteNameError } from '../constants'; + +const sitemapQueryResultNull = { + site: { + siteInfo: null, + }, +}; + +describe('GraphQLSitemapService', () => { + const endpoint = 'http://site'; + const apiKey = 'some-api-key'; + const siteName = 'site-name'; + const mockSitemap = ['sitemap.xml']; + const mockSitemaps = ['sitemap-1.xml', 'sitemap-2.xml', 'sitemap-3.xml']; + + afterEach(() => { + nock.cleanAll(); + }); + + const mockSitemapRequest = (sitemapUrls?: string[]) => { + nock(endpoint) + .post('/') + .reply( + 200, + siteName + ? { + data: { + site: { + siteInfo: { + sitemap: sitemapUrls, + }, + }, + }, + } + : { + data: sitemapQueryResultNull, + } + ); + }; + + describe('Fetch sitemap', () => { + it('should get error if sitemap has empty sitename', async () => { + mockSitemapRequest(); + + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName: '' }); + await service.fetchSitemaps().catch((error: Error) => { + expect(error.message).to.equal(siteNameError); + }); + + return expect(nock.isDone()).to.be.false; + }); + + it('should fetch sitemap', async () => { + mockSitemapRequest(mockSitemap); + + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemaps = await service.fetchSitemaps(); + + expect(sitemaps.length).to.equal(1); + expect(sitemaps).to.deep.equal(mockSitemap); + + return expect(nock.isDone()).to.be.true; + }); + + it('should fetch sitemaps', async () => { + mockSitemapRequest(mockSitemaps); + + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemaps = await service.fetchSitemaps(); + + expect(sitemaps.length).to.equal(3); + expect(sitemaps).to.deep.equal(mockSitemaps); + + return expect(nock.isDone()).to.be.true; + }); + + it('should get exists sitemap', async () => { + const mockIdSitemap = '-3'; + mockSitemapRequest(mockSitemaps); + + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemap = await service.getSitemap(mockIdSitemap); + + expect(sitemap).to.deep.equal(mockSitemaps[2]); + + return expect(nock.isDone()).to.be.true; + }); + + it('should get null if sitemap not exists', async () => { + const mockIdSitemap = '-5'; + mockSitemapRequest(mockSitemaps); + + const service = new GraphQLSitemapService({ endpoint, apiKey, siteName }); + const sitemap = await service.getSitemap(mockIdSitemap); + + // eslint-disable-next-line no-unused-expressions + expect(sitemap).to.be.undefined; + + return expect(nock.isDone()).to.be.true; + }); + }); +}); diff --git a/packages/sitecore-jss/src/site/graphql-sitemap-service.ts b/packages/sitecore-jss/src/site/graphql-sitemap-service.ts new file mode 100644 index 0000000000..7f133de69e --- /dev/null +++ b/packages/sitecore-jss/src/site/graphql-sitemap-service.ts @@ -0,0 +1,102 @@ +import { GraphQLClient, GraphQLRequestClient } from '../graphql'; +import { siteNameError } from '../constants'; +import debug from '../debug'; + +const PREFIX_NAME_SITEMAP = 'sitemap'; + +// The default query for request sitemaps +const defaultQuery = /* GraphQL */ ` + query SitemapQuery($siteName: String!) { + site { + siteInfo(site: $siteName) { + sitemap + } + } + } +`; + +export type GraphQLSitemapServiceConfig = { + /** + * Your Graphql endpoint + */ + endpoint: string; + /** + * The API key to use for authentication + */ + apiKey: string; + /** + * The JSS application name + */ + siteName: string; +}; + +/** + * The schema of data returned in response to sitemaps request + */ +export type SitemapQueryResult = { site: { siteInfo: { sitemap: string[] } } }; + +/** + * Service that fetch the sitemaps data using Sitecore's GraphQL API. + */ +export class GraphQLSitemapService { + private graphQLClient: GraphQLClient; + + protected get query(): string { + return defaultQuery; + } + + /** + * Creates an instance of graphQL sitemaps service with the provided options + * @param {GraphQLSitemapServiceConfig} options instance + */ + constructor(public options: GraphQLSitemapServiceConfig) { + this.graphQLClient = this.getGraphQLClient(); + } + + /** + * Fetch list of sitemaps for the site + * @returns {string[]} list of sitemap paths + * @throws {Error} if the siteName is empty. + */ + async fetchSitemaps(): Promise { + const siteName: string = this.options.siteName; + + if (!siteName) { + throw new Error(siteNameError); + } + + const sitemapResult: Promise = this.graphQLClient.request(this.query, { + siteName, + }); + try { + return sitemapResult.then((result: SitemapQueryResult) => result.site.siteInfo.sitemap); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Get sitemap file path for sitemap id + * @param {string} id the sitemap id (can be empty for default 'sitemap.xml' file) + * @returns {string | undefined} the sitemap file path or undefined if one doesn't exist + */ + async getSitemap(id: string): Promise { + const searchSitemap = `${PREFIX_NAME_SITEMAP}${id}.xml`; + const sitemaps = await this.fetchSitemaps(); + + return sitemaps.find((sitemap: string) => sitemap.includes(searchSitemap)); + } + + /** + * 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, + }); + } +} diff --git a/packages/sitecore-jss/src/site/index.ts b/packages/sitecore-jss/src/site/index.ts index 75ae7781e0..717497d17c 100644 --- a/packages/sitecore-jss/src/site/index.ts +++ b/packages/sitecore-jss/src/site/index.ts @@ -12,3 +12,9 @@ export { GraphQLRedirectsService, GraphQLRedirectsServiceConfig, } from './graphql-redirects-service'; + +export { + SitemapQueryResult, + GraphQLSitemapService, + GraphQLSitemapServiceConfig, +} from './graphql-sitemap-service';