From c8917c989cf208bf9e86931a9d764dfb275df0b9 Mon Sep 17 00:00:00 2001 From: Alexandra Goff Date: Tue, 15 Oct 2024 16:42:47 -0700 Subject: [PATCH] feat: sitemaps (#576) --- app/[locale]/sitemap.xml/route.ts | 69 +++++++++++++++++ app/sitemap_index.xml/route.ts | 30 ++++++++ cypress/e2e/api/sitemaps.cy.js | 55 +++++++++++++ lib/api/homepage/index.ts | 3 +- lib/api/sitemap/index.ts | 124 ++++++++++++++++++++++++++++++ lib/constants/index.ts | 1 + package.json | 1 + yarn.lock | 12 +++ 8 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 app/[locale]/sitemap.xml/route.ts create mode 100644 app/sitemap_index.xml/route.ts create mode 100644 cypress/e2e/api/sitemaps.cy.js create mode 100644 lib/api/sitemap/index.ts create mode 100644 lib/constants/index.ts diff --git a/app/[locale]/sitemap.xml/route.ts b/app/[locale]/sitemap.xml/route.ts new file mode 100644 index 00000000..7f7aaca5 --- /dev/null +++ b/app/[locale]/sitemap.xml/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { XMLBuilder } from "fast-xml-parser"; +import { + generateSitemapUrl, + getSitemapData, + getSiteMapNewsData, +} from "@/lib/api/sitemap"; + +export async function GET( + request: NextRequest, + { params: { locale } }: LocaleProps +) { + const pages = await getSitemapData(locale); + const pageData = pages.map(({ uri, dateUpdated }) => { + return { + loc: generateSitemapUrl(uri, locale), + lastmod: dateUpdated, + }; + }); + const { siteTitle, news } = await getSiteMapNewsData(locale); + + const today = new Date(); + const recentNewsThreshold = new Date( + today.getTime() - 1000 * 60 * 60 * 24 * 2 + ); + + const newsData = news.map(({ uri, dateUpdated, title, date }) => { + const entry = { + loc: generateSitemapUrl(uri, locale), + lastmod: dateUpdated, + }; + + if (new Date(date) > recentNewsThreshold) { + entry["news:news"] = { + "news:publication": { + "news:name": siteTitle, + "news:language": locale, + }, + "news:publication_date": date, + "news:title": title, + }; + } + + return entry; + }); + + const data = { + "?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", + url: pageData.concat(newsData), + }, + }; + const builder = new XMLBuilder({ + attributeNamePrefix: "$", + arrayNodeName: "url", + ignoreAttributes: false, + }); + const output = builder.build(data); + + return new NextResponse(output, { + status: 200, + headers: { "Content-Type": "application/xml; charset=utf-8" }, + }); +} diff --git a/app/sitemap_index.xml/route.ts b/app/sitemap_index.xml/route.ts new file mode 100644 index 00000000..17edc3df --- /dev/null +++ b/app/sitemap_index.xml/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { XMLBuilder } from "fast-xml-parser"; +import { generateSitemapUrl } from "@/lib/api/sitemap"; +import { languages } from "@/lib/i18n/settings"; + +export async function GET() { + const data = { + "?xml": { + $version: "1.0", + $encoding: "UTF-8", + }, + sitemapindex: { + $xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + sitemap: languages.map((language) => { + return { loc: generateSitemapUrl("sitemap.xml", language, true) }; + }), + }, + }; + const builder = new XMLBuilder({ + attributeNamePrefix: "$", + arrayNodeName: "sitemap", + ignoreAttributes: false, + }); + const output = builder.build(data); + + return new NextResponse(output, { + status: 200, + headers: { "Content-Type": "application/xml; charset=utf-8" }, + }); +} diff --git a/cypress/e2e/api/sitemaps.cy.js b/cypress/e2e/api/sitemaps.cy.js new file mode 100644 index 00000000..e7d92770 --- /dev/null +++ b/cypress/e2e/api/sitemaps.cy.js @@ -0,0 +1,55 @@ +import { XMLValidator, XMLParser } from "fast-xml-parser"; +import { languages } from "../../../lib/i18n/settings"; + +const url = "/sitemap_index.xml"; + +context("GET /server_index.xml", () => { + it("returns a well formed XML file", () => { + cy.request({ + url, + method: "GET", + }).then(({ body, headers }) => { + expect(headers["content-type"]).to.eq("application/xml; charset=utf-8"); + + const valid = XMLValidator.validate(body); + + expect(valid).to.eq(true); + }); + }); + it("contains a sitemap for each locale", () => { + cy.request({ + url, + method: "GET", + }).then(({ body }) => { + const parser = new XMLParser(); + const { + sitemapindex: { sitemap }, + } = parser.parse(body); + + expect(sitemap.length).to.eq(languages.length); + }); + }); + it("contains valid sitemap links", () => { + cy.request({ + url, + method: "GET", + }).then(({ body }) => { + const parser = new XMLParser(); + const { + sitemapindex: { sitemap }, + } = parser.parse(body); + + sitemap.forEach(({ loc }) => { + cy.request({ url: loc, method: "GET" }).then(({ body, headers }) => { + expect(headers["content-type"]).to.eq( + "application/xml; charset=utf-8" + ); + + const valid = XMLValidator.validate(body); + + expect(valid).to.eq(true); + }); + }); + }); + }); +}); diff --git a/lib/api/homepage/index.ts b/lib/api/homepage/index.ts index c946f00a..c3aea5c2 100644 --- a/lib/api/homepage/index.ts +++ b/lib/api/homepage/index.ts @@ -7,8 +7,7 @@ import { } from "@/lib/api/fragments/content-blocks"; import { getLinkFields, linkFragment } from "@/lib/api/fragments/link"; import queryAPI from "@/lib/api/client/query"; - -const CRAFT_HOMEPAGE_URI = "__home__"; +import { CRAFT_HOMEPAGE_URI } from "@/lib/constants"; export const getHomepage = async (locale: string, previewToken?: string) => { const site = getSiteFromLocale(locale); diff --git a/lib/api/sitemap/index.ts b/lib/api/sitemap/index.ts new file mode 100644 index 00000000..67bbe004 --- /dev/null +++ b/lib/api/sitemap/index.ts @@ -0,0 +1,124 @@ +import { gql } from "@urql/core"; +import { getSiteFromLocale } from "@/lib/helpers/site"; +import queryAPI from "@/lib/api/client/query"; +import tags from "@/lib/api/client/tags"; +import { CRAFT_HOMEPAGE_URI } from "@/lib/constants"; +import { fallbackLng } from "@/lib/i18n/settings"; + +interface PageMetadata { + uri: string; + dateUpdated: string; +} +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || ""; + +export const generateSitemapUrl = ( + uri: string, + locale: string, + preserveLocale = false +) => { + const segments = uri === CRAFT_HOMEPAGE_URI ? [] : [uri]; + + if (preserveLocale || locale !== fallbackLng) { + segments.unshift(locale); + } + + segments.unshift(baseUrl); + + return segments.join("/"); +}; + +export const getSiteMapNewsData = async (locale: string) => { + const site = getSiteFromLocale(locale); + + const query = gql` + query NewsSitemapData($site: [String]) { + newsEntries(site: $site) { + ... on news_post_Entry { + title + uri + date + dateUpdated + } + } + globalSets(site: $site) { + ... on siteInfo_GlobalSet { + siteTitle + } + } + } + `; + + const { data } = await queryAPI({ + query, + variables: { site }, + fetchOptions: { + next: { tags: [tags.globals] }, + }, + }); + + const { newsEntries, globalSets } = data; + const { siteTitle } = globalSets.filter((set) => + Object.hasOwn(set, "siteTitle") + )[0]; + + return { siteTitle, news: newsEntries }; +}; + +export const getSitemapData = async ( + locale: string +): Promise> => { + const site = getSiteFromLocale(locale); + + const query = gql` + query SitemapData($site: [String]) { + pages: entries( + site: $site + section: ["pages"] + type: ["not", "redirectPage"] + ) { + uri + dateUpdated + } + homepage: homepageEntries(site: $site) { + ... on homepage_homepage_Entry { + uri + dateUpdated + } + } + staff: staffProfilesEntries(site: $site) { + ... on staffProfiles_staffProfiles_Entry { + dateUpdated + uri + } + } + slideshows: slideshowsEntries(site: $site) { + ... on slideshows_slideshow_Entry { + dateUpdated + uri + } + } + glossary: glossaryTermsEntries(site: $site) { + ... on glossaryTerms_glossaryTerm_Entry { + dateUpdated + uri + } + } + voice: slideshowsEntries(site: $site) { + ... on slideshows_slideshow_Entry { + dateUpdated + uri + } + } + } + `; + + const { data } = await queryAPI({ + query, + variables: { site }, + fetchOptions: { + next: { tags: [tags.globals] }, + }, + }); + + return Object.values(data).flat(); +}; diff --git a/lib/constants/index.ts b/lib/constants/index.ts new file mode 100644 index 00000000..f01623c7 --- /dev/null +++ b/lib/constants/index.ts @@ -0,0 +1 @@ +export const CRAFT_HOMEPAGE_URI = "__home__"; diff --git a/package.json b/package.json index 194cdf5a..10ed5450 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "add": "^2.0.6", "classnames": "^2.3.1", "convert": "^4.13.0", + "fast-xml-parser": "^4.5.0", "feed": "^4.2.2", "focus-trap": "^7.0.0", "focus-visible": "^5.1.0", diff --git a/yarn.lock b/yarn.lock index c017864a..15ea17d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6695,6 +6695,13 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-xml-parser@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz#2882b7d01a6825dfdf909638f2de0256351def37" + integrity sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg== + dependencies: + strnum "^1.0.5" + fastest-levenshtein@^1.0.16: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -11613,6 +11620,11 @@ striptags@^3.2.0: resolved "https://registry.yarnpkg.com/striptags/-/striptags-3.2.0.tgz#cc74a137db2de8b0b9a370006334161f7dd67052" integrity sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw== +strnum@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" + integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== + style-loader@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.2.tgz#eaebca714d9e462c19aa1e3599057bc363924899"