diff --git a/.gitignore b/.gitignore index 6d94c4c8..cf5b68bb 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ junit.xml tsconfig.tsbuildinfo **/public **/public +.idea diff --git a/README.md b/README.md index 5765cdfc..f57050fd 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,40 @@ module.exports = { } ``` +## Google News, image and video sitemap + +Url set can contain additional sitemaps defined by google. These are +[Google News sitemap](https://developers.google.com/search/docs/advanced/sitemaps/news-sitemap), +[image sitemap](https://developers.google.com/search/docs/advanced/sitemaps/image-sitemaps) or +[video sitemap](https://developers.google.com/search/docs/advanced/sitemaps/video-sitemaps). +You can add the values for these sitemaps by updating entry in `transform` function or adding it with +`additionalPaths`. You have to return a sitemap entry in both cases, so it's the best place for updating +the output. This example will add an image and news tag to each entry but IRL you would of course use it with +some condition or within `additionalPaths` result. + +```js +/** @type {import('next-sitemap').IConfig} */ +const config = { + transform: async (config, path) => { + return { + loc: path, // => this will be exported as http(s):/// + changefreq: config.changefreq, + priority: config.priority, + lastmod: config.autoLastmod ? new Date().toISOString() : undefined, + images: [{ loc: 'https://example.com/image.jpg' }], + news: { + title: 'Article 1', + publicationName: 'Google Scholar', + publicationLanguage: 'en', + date: new Date(), + }, + } + }, +} + +export default config +``` + ## Full configuration example Here's an example `next-sitemap.config.js` configuration with all options diff --git a/jest.config.js b/jest.config.js index e8105e29..ac4cf36b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ import reporter from '@corex/jest/reporter.js' - +process.env.TZ = 'UTC' export default { ...reporter, verbose: true, diff --git a/packages/next-sitemap/src/builders/__tests__/sitemap-builder/build-sitemap-xml.test.ts b/packages/next-sitemap/src/builders/__tests__/sitemap-builder/build-sitemap-xml.test.ts index 876aae25..a4f22f9c 100644 --- a/packages/next-sitemap/src/builders/__tests__/sitemap-builder/build-sitemap-xml.test.ts +++ b/packages/next-sitemap/src/builders/__tests__/sitemap-builder/build-sitemap-xml.test.ts @@ -36,4 +36,116 @@ describe('SitemapBuilder', () => { " `) }) + test('snapshot test for google news sitemap', () => { + // Builder instance + const builder = new SitemapBuilder() + + // Build content + const content = builder.buildSitemapXml([ + { + loc: 'https://example.com', + news: { + title: 'Companies A, B in Merger Talks', + date: new Date('2008-01-02T00:00:00.000+01:00'), + publicationLanguage: 'en', + publicationName: 'The Example Times', + }, + }, + ]) + + // Expect the generated sitemap to match snapshot. + expect(content).toMatchInlineSnapshot(` + " + + https://example.comThe Example Timesen2008-01-01T23:00:00.000+00:00Companies A, B in Merger Talks + " + `) + }) + test('snapshot test for image sitemap', () => { + // Builder instance + const builder = new SitemapBuilder() + + // Build content + const content = builder.buildSitemapXml([ + { + loc: 'https://example.com', + images: [ + { + loc: new URL('https://example.com'), + }, + { + caption: 'Image caption & description', + geoLocation: 'Prague, Czech Republic', + license: new URL('https://example.com'), + loc: new URL('https://example.com'), + title: 'Image title', + }, + ], + }, + ]) + + // Expect the generated sitemap to match snapshot. + expect(content).toMatchInlineSnapshot(` + " + + https://example.comhttps://example.com/https://example.com/Image caption & descriptionImage titlePrague, Czech Republichttps://example.com/ + " + `) + }) + test('snapshot test for video sitemap', () => { + // Builder instance + const builder = new SitemapBuilder() + + // Build content + const content = builder.buildSitemapXml([ + { + loc: 'https://example.com', + videos: [ + { + title: 'Video title', + contentLoc: new URL('https://example.com'), + description: 'Video description', + thumbnailLoc: new URL('https://example.com'), + }, + { + title: 'Grilling steaks for summer', + contentLoc: new URL('https://example.com'), + description: + 'Alkis shows you how to get perfectly done steaks every time', + thumbnailLoc: new URL('https://example.com'), + duration: 600, + expirationDate: new Date('2030-03-02T00:00:00.000+01:00'), + familyFriendly: true, + live: false, + platform: { + relationship: 'allow', + content: 'web', + }, + playerLoc: new URL('https://example.com'), + publicationDate: new Date('2020-04-20T00:00:00.000+02:00'), + rating: 1, + requiresSubscription: false, + restriction: { + relationship: 'deny', + content: 'CZ', + }, + tag: 'video', + uploader: { + name: 'John Doe', + info: new URL('https://example.com'), + }, + viewCount: 1234, + }, + ], + }, + ]) + + // Expect the generated sitemap to match snapshot. + expect(content).toMatchInlineSnapshot(` + " + + https://example.comVideo titlehttps://example.com/Video descriptionhttps://example.com/Grilling steaks for summerhttps://example.com/Alkis shows you how to get perfectly done steaks every timehttps://example.com/https://example.com/6001234video1.02030-03-01T23:00:00.000+00:002020-04-19T22:00:00.000+00:00yesnonoCZwebJohn Doe + " + `) + }) }) diff --git a/packages/next-sitemap/src/builders/sitemap-builder.ts b/packages/next-sitemap/src/builders/sitemap-builder.ts index fb253930..ae97ee5e 100644 --- a/packages/next-sitemap/src/builders/sitemap-builder.ts +++ b/packages/next-sitemap/src/builders/sitemap-builder.ts @@ -1,4 +1,10 @@ -import type { ISitemapField, IAlternateRef } from '../interface.js' +import type { + IAlternateRef, + IGoogleNewsEntry, + IImageEntry, + ISitemapField, + IVideoEntry, +} from '../interface.js' /** * Builder class to generate xml and robots.txt @@ -48,6 +54,49 @@ export class SitemapBuilder { } } + /** + * Composes YYYY-MM-DDThh:mm:ssTZD date format (with TZ offset) + * (ref: https://stackoverflow.com/a/49332027) + * @param date + * @private + */ + private formatDate(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date + const z = (n) => ('0' + n).slice(-2) + const zz = (n) => ('00' + n).slice(-3) + let off = d.getTimezoneOffset() + const sign = off > 0 ? '-' : '+' + off = Math.abs(off) + + return ( + d.getFullYear() + + '-' + + z(d.getMonth() + 1) + + '-' + + z(d.getDate()) + + 'T' + + z(d.getHours()) + + ':' + + z(d.getMinutes()) + + ':' + + z(d.getSeconds()) + + '.' + + zz(d.getMilliseconds()) + + sign + + z((off / 60) | 0) + + ':' + + z(off % 60) + ) + } + + private formatBoolean(value: boolean): string { + return value ? 'yes' : 'no' + } + + private escapeHtml(s: string) { + return s.replace(/[^\dA-Za-z ]/g, (c) => '&#' + c.charCodeAt(0) + ';') + } + /** * Generates sitemap.xml * @param fields @@ -70,14 +119,33 @@ export class SitemapBuilder { } if (field[key]) { - if (key !== 'alternateRefs') { - fieldArr.push(`<${key}>${field[key]}`) - } else { + if (key === 'alternateRefs') { const altRefField = this.buildAlternateRefsXml( field.alternateRefs ) fieldArr.push(altRefField) + } else if (key === 'news') { + if (field.news) { + const newsField = this.buildNewsXml(field.news) + fieldArr.push(newsField) + } + } else if (key === 'images') { + if (field.images) { + for (const image of field.images) { + const imageField = this.buildImageXml(image) + fieldArr.push(imageField) + } + } + } else if (key === 'videos') { + if (field.videos) { + for (const video of field.videos) { + const videoField = this.buildVideoXml(video) + fieldArr.push(videoField) + } + } + } else { + fieldArr.push(`<${key}>${field[key]}`) } } } @@ -102,4 +170,118 @@ export class SitemapBuilder { }) .join('') } + + /** + * Generate Google News sitemap entry + * @param news + * @returns string + */ + buildNewsXml(news: IGoogleNewsEntry): string { + // using array just because it looks more structured + return [ + ``, + ...[ + ``, + ...[ + `${this.escapeHtml(news.publicationName)}`, + `${news.publicationLanguage}`, + ], + ``, + `${this.formatDate( + news.date + )}`, + `${this.escapeHtml(news.title)}`, + ], + ``, + ] + .filter(Boolean) + .join('') + } + + /** + * Generate Image sitemap entry + * @param image + * @returns string + */ + buildImageXml(image: IImageEntry): string { + // using array just because it looks more structured + return [ + ``, + ...[ + `${image.loc.href}`, + image.caption && + `${this.escapeHtml(image.caption)}`, + image.title && + `${this.escapeHtml(image.title)}`, + image.geoLocation && + `${this.escapeHtml( + image.geoLocation + )}`, + image.license && `${image.license.href}`, + ], + ``, + ] + .filter(Boolean) + .join('') + } + + /** + * Generate Video sitemap entry + * @param video + * @returns string + */ + buildVideoXml(video: IVideoEntry): string { + // using array just because it looks more structured + return [ + ``, + ...[ + `${this.escapeHtml(video.title)}`, + `${video.thumbnailLoc.href}`, + `${this.escapeHtml( + video.description + )}`, + video.contentLoc && + `${video.contentLoc.href}`, + video.playerLoc && + `${video.playerLoc.href}`, + video.duration && `${video.duration}`, + video.viewCount && + `${video.viewCount}`, + video.tag && `${this.escapeHtml(video.tag)}`, + video.rating && + `${video.rating + .toFixed(1) + .replace(',', '.')}`, + video.expirationDate && + `${this.formatDate( + video.expirationDate + )}`, + video.publicationDate && + `${this.formatDate( + video.publicationDate + )}`, + typeof video.familyFriendly !== 'undefined' && + `${this.formatBoolean( + video.familyFriendly + )}`, + typeof video.requiresSubscription !== 'undefined' && + `${this.formatBoolean( + video.requiresSubscription + )}`, + typeof video.live !== 'undefined' && + `${this.formatBoolean(video.live)}`, + video.restriction && + `${video.restriction.content}`, + video.platform && + `${video.platform.content}`, + video.uploader && + `${this.escapeHtml(video.uploader.name)}`, + ], + ``, + ] + .filter(Boolean) + .join('') + } } diff --git a/packages/next-sitemap/src/interface.ts b/packages/next-sitemap/src/interface.ts index 8c8a0843..09f4acb0 100644 --- a/packages/next-sitemap/src/interface.ts +++ b/packages/next-sitemap/src/interface.ts @@ -236,6 +236,49 @@ export type IAlternateRef = { hrefIsAbsolute?: boolean } +export type IGoogleNewsEntry = { + title: string + date: Date | string + publicationName: string + publicationLanguage: string +} + +export type IImageEntry = { + loc: URL + caption?: string + geoLocation?: string + title?: string + license?: URL +} + +export type IRestriction = { + relationship: 'allow' | 'deny' + content: string +} + +export type IVideoEntry = { + title: string + thumbnailLoc: URL + description: string + contentLoc?: URL + playerLoc?: URL + duration?: number + expirationDate?: Date | string + rating?: number + viewCount?: number + publicationDate?: Date | string + familyFriendly?: boolean + restriction?: IRestriction + platform?: IRestriction + requiresSubscription?: boolean + uploader?: { + name: string + info?: URL + } + live?: boolean + tag?: string +} + export type ISitemapField = { loc: string lastmod?: string @@ -243,6 +286,10 @@ export type ISitemapField = { priority?: number alternateRefs?: Array trailingSlash?: boolean + + news?: IGoogleNewsEntry + images?: Array + videos?: Array } export interface INextSitemapResult {