Skip to content

Commit

Permalink
Merge pull request #437 from zuffik/feature/google-news-video-image
Browse files Browse the repository at this point in the history
created google news, image and video support
  • Loading branch information
iamvishnusankar authored Mar 7, 2023
2 parents 368121f + 815d2fa commit 6911a3f
Show file tree
Hide file tree
Showing 6 changed files with 381 additions and 5 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ junit.xml
tsconfig.tsbuildinfo
**/public
**/public
.idea
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)://<config.siteUrl>/<path>
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
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import reporter from '@corex/jest/reporter.js'

process.env.TZ = 'UTC'
export default {
...reporter,
verbose: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,116 @@ describe('SitemapBuilder', () => {
</urlset>"
`)
})
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(`
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<urlset xmlns=\\"http://www.sitemaps.org/schemas/sitemap/0.9\\" xmlns:news=\\"http://www.google.com/schemas/sitemap-news/0.9\\" xmlns:xhtml=\\"http://www.w3.org/1999/xhtml\\" xmlns:mobile=\\"http://www.google.com/schemas/sitemap-mobile/1.0\\" xmlns:image=\\"http://www.google.com/schemas/sitemap-image/1.1\\" xmlns:video=\\"http://www.google.com/schemas/sitemap-video/1.1\\">
<url><loc>https://example.com</loc><news:news><news:publication><news:name>The Example Times</news:name><news:language>en</news:language></news:publication><news:publication_date>2008-01-01T23:00:00.000+00:00</news:publication_date><news:title>Companies A&#44; B in Merger Talks</news:title></news:news></url>
</urlset>"
`)
})
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(`
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<urlset xmlns=\\"http://www.sitemaps.org/schemas/sitemap/0.9\\" xmlns:news=\\"http://www.google.com/schemas/sitemap-news/0.9\\" xmlns:xhtml=\\"http://www.w3.org/1999/xhtml\\" xmlns:mobile=\\"http://www.google.com/schemas/sitemap-mobile/1.0\\" xmlns:image=\\"http://www.google.com/schemas/sitemap-image/1.1\\" xmlns:video=\\"http://www.google.com/schemas/sitemap-video/1.1\\">
<url><loc>https://example.com</loc><image:image><image:loc>https://example.com/</image:loc></image:image><image:image><image:loc>https://example.com/</image:loc><image:caption>Image caption &#38; description</image:caption><image:title>Image title</image:title><image:geo_location>Prague&#44; Czech Republic</image:geo_location><image:license>https://example.com/</image:license></image:image></url>
</urlset>"
`)
})
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(`
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<urlset xmlns=\\"http://www.sitemaps.org/schemas/sitemap/0.9\\" xmlns:news=\\"http://www.google.com/schemas/sitemap-news/0.9\\" xmlns:xhtml=\\"http://www.w3.org/1999/xhtml\\" xmlns:mobile=\\"http://www.google.com/schemas/sitemap-mobile/1.0\\" xmlns:image=\\"http://www.google.com/schemas/sitemap-image/1.1\\" xmlns:video=\\"http://www.google.com/schemas/sitemap-video/1.1\\">
<url><loc>https://example.com</loc><video:video><video:title>Video title</video:title><video:thumbnail_loc>https://example.com/</video:thumbnail_loc><video:description>Video description</video:description><video:content_loc>https://example.com/</video:content_loc></video:video><video:video><video:title>Grilling steaks for summer</video:title><video:thumbnail_loc>https://example.com/</video:thumbnail_loc><video:description>Alkis shows you how to get perfectly done steaks every time</video:description><video:content_loc>https://example.com/</video:content_loc><video:player_loc>https://example.com/</video:player_loc><video:duration>600</video:duration><video:view_count>1234</video:view_count><video:tag>video</video:tag><video:rating>1.0</video:rating><video:expiration_date>2030-03-01T23:00:00.000+00:00</video:expiration_date><video:publication_date>2020-04-19T22:00:00.000+00:00</video:publication_date><video:family_friendly>yes</video:family_friendly><video:requires_subscription>no</video:requires_subscription><video:live>no</video:live><video:restriction relationship=\\"deny\\">CZ</video:restriction><video:platform relationship=\\"allow\\">web</video:platform><video:uploader info=\\"https://example.com/\\">John Doe</video:uploader></video:video></url>
</urlset>"
`)
})
})
190 changes: 186 additions & 4 deletions packages/next-sitemap/src/builders/sitemap-builder.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -70,14 +119,33 @@ export class SitemapBuilder {
}

if (field[key]) {
if (key !== 'alternateRefs') {
fieldArr.push(`<${key}>${field[key]}</${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]}</${key}>`)
}
}
}
Expand All @@ -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 [
`<news:news>`,
...[
`<news:publication>`,
...[
`<news:name>${this.escapeHtml(news.publicationName)}</news:name>`,
`<news:language>${news.publicationLanguage}</news:language>`,
],
`</news:publication>`,
`<news:publication_date>${this.formatDate(
news.date
)}</news:publication_date>`,
`<news:title>${this.escapeHtml(news.title)}</news:title>`,
],
`</news:news>`,
]
.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:image>`,
...[
`<image:loc>${image.loc.href}</image:loc>`,
image.caption &&
`<image:caption>${this.escapeHtml(image.caption)}</image:caption>`,
image.title &&
`<image:title>${this.escapeHtml(image.title)}</image:title>`,
image.geoLocation &&
`<image:geo_location>${this.escapeHtml(
image.geoLocation
)}</image:geo_location>`,
image.license && `<image:license>${image.license.href}</image:license>`,
],
`</image:image>`,
]
.filter(Boolean)
.join('')
}

/**
* Generate Video sitemap entry
* @param video
* @returns string
*/
buildVideoXml(video: IVideoEntry): string {
// using array just because it looks more structured
return [
`<video:video>`,
...[
`<video:title>${this.escapeHtml(video.title)}</video:title>`,
`<video:thumbnail_loc>${video.thumbnailLoc.href}</video:thumbnail_loc>`,
`<video:description>${this.escapeHtml(
video.description
)}</video:description>`,
video.contentLoc &&
`<video:content_loc>${video.contentLoc.href}</video:content_loc>`,
video.playerLoc &&
`<video:player_loc>${video.playerLoc.href}</video:player_loc>`,
video.duration && `<video:duration>${video.duration}</video:duration>`,
video.viewCount &&
`<video:view_count>${video.viewCount}</video:view_count>`,
video.tag && `<video:tag>${this.escapeHtml(video.tag)}</video:tag>`,
video.rating &&
`<video:rating>${video.rating
.toFixed(1)
.replace(',', '.')}</video:rating>`,
video.expirationDate &&
`<video:expiration_date>${this.formatDate(
video.expirationDate
)}</video:expiration_date>`,
video.publicationDate &&
`<video:publication_date>${this.formatDate(
video.publicationDate
)}</video:publication_date>`,
typeof video.familyFriendly !== 'undefined' &&
`<video:family_friendly>${this.formatBoolean(
video.familyFriendly
)}</video:family_friendly>`,
typeof video.requiresSubscription !== 'undefined' &&
`<video:requires_subscription>${this.formatBoolean(
video.requiresSubscription
)}</video:requires_subscription>`,
typeof video.live !== 'undefined' &&
`<video:live>${this.formatBoolean(video.live)}</video:live>`,
video.restriction &&
`<video:restriction relationship="${video.restriction.relationship}">${video.restriction.content}</video:restriction>`,
video.platform &&
`<video:platform relationship="${video.platform.relationship}">${video.platform.content}</video:platform>`,
video.uploader &&
`<video:uploader${
video.uploader.info && ` info="${video.uploader.info}"`
}>${this.escapeHtml(video.uploader.name)}</video:uploader>`,
],
`</video:video>`,
]
.filter(Boolean)
.join('')
}
}
Loading

0 comments on commit 6911a3f

Please sign in to comment.