feat(BOOK-58): Added destination filter pages to sitemap

Approved-by: Linus Flood
This commit is contained in:
Erik Tiekstra
2025-09-30 13:17:14 +00:00
parent 0d9f38857b
commit 0bcde9f74f
4 changed files with 234 additions and 18 deletions

View File

@@ -9,13 +9,18 @@ import {
getEntries,
getSyncToken,
saveEntries,
saveHotelFilters,
saveLastUpdatedDate,
saveSitemapData,
saveSyncToken,
} from "@/utils/sitemap"
import { contentstackSync } from "./sync"
import { mapEntriesToSitemapData, mergeEntries } from "./utils"
import {
mapEntriesToHotelFilters,
mapEntriesToSitemapData,
mergeEntries,
} from "./utils"
export const dynamic = "force-dynamic"
@@ -36,7 +41,6 @@ export async function GET(request: NextRequest) {
const syncToken = await getSyncToken()
const currentEntries = await getEntries()
const responseData = await contentstackSync(syncToken)
const mergedEntries = mergeEntries(currentEntries, responseData.entries)
@@ -49,7 +53,10 @@ export async function GET(request: NextRequest) {
await saveEntries(mergedEntries)
metricsEntriesSave.success()
const sitemapData = mapEntriesToSitemapData(mergedEntries)
const hotelFilters = mapEntriesToHotelFilters(mergedEntries)
await saveHotelFilters(hotelFilters)
const sitemapData = mapEntriesToSitemapData(mergedEntries, hotelFilters)
const lastUpdated = dt().utc().format()
const saveDataCounter = createCounter("sitemap", "data.save")

View File

@@ -4,7 +4,12 @@ import { removeTrailingSlash } from "@scandic-hotels/common/utils/url"
import { env } from "@/env/server"
import type { SyncItem } from "@/types/sitemap"
import type {
HotelFilterEntries,
HotelFilterEntry,
SeoFilter,
SyncItem,
} from "@/types/sitemap"
export function mergeEntries(
currentEntries: SyncItem[],
@@ -50,7 +55,44 @@ export function mergeEntries(
return entries
}
export function mapEntriesToSitemapData(entries: SyncItem[]) {
export function mapEntriesToHotelFilters(entries: SyncItem[]) {
const entriesTransformToHotelFiltersCounter = createCounter(
"sitemap",
"entries.transform.hotelFilters"
)
const metricsEntriesTransformToHotelFilters =
entriesTransformToHotelFiltersCounter.init({
entriesCount: entries.length,
})
metricsEntriesTransformToHotelFilters.start()
const filteredEntries = entries
.filter(
(entry) => entry.content_type_uid === "hotel_filter" && entry.data.slug
)
.map((entry) => ({
uid: entry.data.uid,
locale: entry.data.locale,
slug: entry.data.slug,
}))
const entriesByUid = groupHotelFilterEntriesByUid(
filteredEntries as HotelFilterEntry[]
)
metricsEntriesTransformToHotelFilters.success({
entriesCount: entries.length,
hotelFilterEntriesCount: filteredEntries.length,
uniqueHotelFilterUidsCount: Object.keys(entriesByUid).length,
})
return entriesByUid
}
export function mapEntriesToSitemapData(
entries: SyncItem[],
hotelFilters: HotelFilterEntries
) {
const entriesTransformCounter = createCounter("sitemap", "entries.transform")
const metricsEntriesTransform = entriesTransformCounter.init({
entriesCount: entries.length,
@@ -62,7 +104,7 @@ export function mapEntriesToSitemapData(entries: SyncItem[]) {
const entriesByUid = groupEntriesByUid(filteredEntries)
const sitemapEntries = Object.entries(entriesByUid)
.map(([_, entries]) => mapEntriesToSitemapEntries(entries))
.map(([_, entries]) => mapEntriesToSitemapEntries(entries, hotelFilters))
.flat()
metricsEntriesTransform.success({
@@ -94,9 +136,39 @@ function groupEntriesByUid(entries: SyncItem[]) {
}, {})
}
function mapEntriesToSitemapEntries(entries: SyncItem[]) {
const alternates = mapToAlternates(entries)
return entries.map((currentEntry) => ({
function groupHotelFilterEntriesByUid(entries: HotelFilterEntry[]) {
return entries.reduce<HotelFilterEntries>((acc, entry) => {
const uid = entry.uid
if (!acc[uid]) {
acc[uid] = []
}
acc[uid].push(entry)
return acc
}, {})
}
function isDestinationPageWithSeoFilters(entry: SyncItem) {
const destinationPageUids = [
"destination_city_page",
"destination_country_page",
]
return (
destinationPageUids.includes(entry.content_type_uid) &&
!!entry.data.seo_filters?.length
)
}
function mapEntriesToSitemapEntries(
entries: SyncItem[],
hotelFilters: HotelFilterEntries
) {
const alternates = getAlternates(entries)
const seoFilterEntries = mapEntriesToSeoFilterSitemapEntries(
entries,
hotelFilters
)
const pageEntries = entries.map((currentEntry) => ({
url: removeTrailingSlash(
`${env.PUBLIC_URL}/${currentEntry.data.locale}${currentEntry.data.url}`
),
@@ -106,9 +178,84 @@ function mapEntriesToSitemapEntries(entries: SyncItem[]) {
priority: currentEntry.data.web?.seo_metadata?.sitemap?.priority ?? 0.5,
alternates,
}))
return [...pageEntries, ...seoFilterEntries]
}
function mapToAlternates(entries: SyncItem[]) {
function mapEntriesToSeoFilterSitemapEntries(
entries: SyncItem[],
hotelFilters: HotelFilterEntries
) {
const relevantEntries = entries.filter((entry) =>
isDestinationPageWithSeoFilters(entry)
)
if (!relevantEntries.length) {
return []
}
return relevantEntries.flatMap((entry) => {
const seoFilters = entry.data.seo_filters?.filter(
(seoFilter) => !seoFilter.seo_metadata?.noindex
)
if (!seoFilters?.length) {
return []
}
return seoFilters
.map((seoFilter) =>
getEntryBySeoFilter(entry, entries, seoFilter, hotelFilters)
)
.filter((e): e is NonNullable<typeof e> => !!e)
})
}
function getEntryBySeoFilter(
currentEntry: SyncItem,
entries: SyncItem[],
seoFilter: SeoFilter,
hotelFilters: HotelFilterEntries
) {
const filterUid = seoFilter.filter?.[0]?.uid
if (!filterUid) {
return null
}
const locale = currentEntry.data.locale
const baseUrl = removeTrailingSlash(
`${env.PUBLIC_URL}/${locale}${currentEntry.data.url}`
)
const defaultMetadata = currentEntry.data.web?.seo_metadata
const matchedHotelFilter = hotelFilters[filterUid]
const matchedHotelFilterForLocale = matchedHotelFilter?.find(
(f) => f.locale === locale
)
if (!matchedHotelFilterForLocale) {
return null
}
const alternates = getHotelFilterAlternates(
entries,
matchedHotelFilter,
filterUid
)
return {
url: removeTrailingSlash(`${baseUrl}/${matchedHotelFilterForLocale.slug}`),
lastModified: currentEntry.data.updated_at,
changeFrequency:
seoFilter.seo_metadata?.sitemap?.change_frequency ??
defaultMetadata?.sitemap?.change_frequency ??
"daily",
priority:
seoFilter.seo_metadata?.sitemap?.priority ??
defaultMetadata?.sitemap?.priority ??
0.5,
alternates,
}
}
function getAlternates(entries: SyncItem[]) {
return entries.reduce<Partial<Record<Lang, string>>>((acc, entry) => {
acc[entry.data.locale] = removeTrailingSlash(
`${env.PUBLIC_URL}/${entry.data.locale}${entry.data.url}`
@@ -116,3 +263,31 @@ function mapToAlternates(entries: SyncItem[]) {
return acc
}, {})
}
function getHotelFilterAlternates(
entries: SyncItem[],
matchedHotelFilter: HotelFilterEntry[],
filterUid: string
) {
const matchingFilterInOtherLocales = entries.filter((entry) =>
entry.data.seo_filters?.some(
(sf) =>
sf.filter?.some((f) => f.uid === filterUid) && !sf.seo_metadata?.noindex
)
)
return matchingFilterInOtherLocales.reduce<Partial<Record<Lang, string>>>(
(acc, entry) => {
const locale = entry.data.locale
const foundFilter = matchedHotelFilter?.find((f) => f.locale === locale)
if (!foundFilter) {
return acc
}
acc[locale] = removeTrailingSlash(
`${env.PUBLIC_URL}/${locale}${entry.data.url}/${foundFilter.slug}`
)
return acc
},
{}
)
}

View File

@@ -20,23 +20,43 @@ export interface SitemapEntry {
export type SitemapData = SitemapEntry[]
export interface SeoMetadata {
noindex?: boolean | null
sitemap?: {
change_frequency: SitemapEntry["changeFrequency"]
priority: SitemapEntry["priority"]
} | null
}
export interface SeoFilter {
filter?: {
uid?: string
}[]
seo_metadata?: SeoMetadata
}
export interface HotelFilterEntry {
uid: string
locale: Lang
slug: string
}
export type HotelFilterEntries = Record<string, HotelFilterEntry[]>
export interface SyncItemData {
uid: string
locale: Lang
url?: string
updated_at: string
web?: {
seo_metadata?: {
noindex?: boolean | null
sitemap?: {
change_frequency: SitemapEntry["changeFrequency"]
priority: SitemapEntry["priority"]
} | null
}
seo_metadata?: SeoMetadata
}
// SEO Filters on destination pages
slug?: string
seo_filters?: SeoFilter[]
}
export interface SyncItem {
type: string
content_type_uid: string
data: SyncItemData
}
export interface SyncResponse extends Omit<SyncResult, "items"> {

View File

@@ -2,11 +2,12 @@ import { getStore } from "@netlify/blobs"
import { env } from "@/env/server"
import type { SitemapData, SyncItem } from "@/types/sitemap"
import type { HotelFilterEntry, SitemapData, SyncItem } from "@/types/sitemap"
const branch = env.CMS_BRANCH
const environment = env.CMS_ENVIRONMENT
const entriesKey = `${environment}/${branch}/entries`
const hotelFiltersKey = `${environment}/${branch}/hotelFilters`
const syncTokenKey = `${environment}/${branch}/syncToken`
const sitemapDataKey = `${environment}/${branch}/sitemapData`
const lastUpdatedKey = `${environment}/${branch}/lastUpdated`
@@ -14,6 +15,13 @@ const MAX_ENTRIES_PER_SITEMAP = 50000
// We need to wrap `getStore` because calling it in the root of the file causes
// it to be executed during build time. This is not supported by Netlify.
// To run this locally, you need to change the arguments to `getStore` like this:
// return getStore({
// name: "sitemap",
// siteID: "SITE_ID from netlify",
// token: "Personal access token from Netlify",
// })
// See https://docs.netlify.com/build/data-and-storage/netlify-blobs/#getstore for more info.
function store() {
return getStore("sitemap")
}
@@ -22,6 +30,12 @@ export async function saveEntries(entries: SyncItem[]) {
await store().setJSON(entriesKey, entries)
}
export async function saveHotelFilters(
hotelFilters: Record<string, HotelFilterEntry[]>
) {
await store().setJSON(hotelFiltersKey, hotelFilters)
}
export async function saveSitemapData(sitemapData: SitemapData) {
await store().setJSON(sitemapDataKey, sitemapData)
}