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, getEntries,
getSyncToken, getSyncToken,
saveEntries, saveEntries,
saveHotelFilters,
saveLastUpdatedDate, saveLastUpdatedDate,
saveSitemapData, saveSitemapData,
saveSyncToken, saveSyncToken,
} from "@/utils/sitemap" } from "@/utils/sitemap"
import { contentstackSync } from "./sync" import { contentstackSync } from "./sync"
import { mapEntriesToSitemapData, mergeEntries } from "./utils" import {
mapEntriesToHotelFilters,
mapEntriesToSitemapData,
mergeEntries,
} from "./utils"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -36,7 +41,6 @@ export async function GET(request: NextRequest) {
const syncToken = await getSyncToken() const syncToken = await getSyncToken()
const currentEntries = await getEntries() const currentEntries = await getEntries()
const responseData = await contentstackSync(syncToken) const responseData = await contentstackSync(syncToken)
const mergedEntries = mergeEntries(currentEntries, responseData.entries) const mergedEntries = mergeEntries(currentEntries, responseData.entries)
@@ -49,7 +53,10 @@ export async function GET(request: NextRequest) {
await saveEntries(mergedEntries) await saveEntries(mergedEntries)
metricsEntriesSave.success() metricsEntriesSave.success()
const sitemapData = mapEntriesToSitemapData(mergedEntries) const hotelFilters = mapEntriesToHotelFilters(mergedEntries)
await saveHotelFilters(hotelFilters)
const sitemapData = mapEntriesToSitemapData(mergedEntries, hotelFilters)
const lastUpdated = dt().utc().format() const lastUpdated = dt().utc().format()
const saveDataCounter = createCounter("sitemap", "data.save") 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 { env } from "@/env/server"
import type { SyncItem } from "@/types/sitemap" import type {
HotelFilterEntries,
HotelFilterEntry,
SeoFilter,
SyncItem,
} from "@/types/sitemap"
export function mergeEntries( export function mergeEntries(
currentEntries: SyncItem[], currentEntries: SyncItem[],
@@ -50,7 +55,44 @@ export function mergeEntries(
return entries 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 entriesTransformCounter = createCounter("sitemap", "entries.transform")
const metricsEntriesTransform = entriesTransformCounter.init({ const metricsEntriesTransform = entriesTransformCounter.init({
entriesCount: entries.length, entriesCount: entries.length,
@@ -62,7 +104,7 @@ export function mapEntriesToSitemapData(entries: SyncItem[]) {
const entriesByUid = groupEntriesByUid(filteredEntries) const entriesByUid = groupEntriesByUid(filteredEntries)
const sitemapEntries = Object.entries(entriesByUid) const sitemapEntries = Object.entries(entriesByUid)
.map(([_, entries]) => mapEntriesToSitemapEntries(entries)) .map(([_, entries]) => mapEntriesToSitemapEntries(entries, hotelFilters))
.flat() .flat()
metricsEntriesTransform.success({ metricsEntriesTransform.success({
@@ -94,9 +136,39 @@ function groupEntriesByUid(entries: SyncItem[]) {
}, {}) }, {})
} }
function mapEntriesToSitemapEntries(entries: SyncItem[]) { function groupHotelFilterEntriesByUid(entries: HotelFilterEntry[]) {
const alternates = mapToAlternates(entries) return entries.reduce<HotelFilterEntries>((acc, entry) => {
return entries.map((currentEntry) => ({ 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( url: removeTrailingSlash(
`${env.PUBLIC_URL}/${currentEntry.data.locale}${currentEntry.data.url}` `${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, priority: currentEntry.data.web?.seo_metadata?.sitemap?.priority ?? 0.5,
alternates, 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) => { return entries.reduce<Partial<Record<Lang, string>>>((acc, entry) => {
acc[entry.data.locale] = removeTrailingSlash( acc[entry.data.locale] = removeTrailingSlash(
`${env.PUBLIC_URL}/${entry.data.locale}${entry.data.url}` `${env.PUBLIC_URL}/${entry.data.locale}${entry.data.url}`
@@ -116,3 +263,31 @@ function mapToAlternates(entries: SyncItem[]) {
return acc 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 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 { export interface SyncItemData {
uid: string uid: string
locale: Lang locale: Lang
url?: string url?: string
updated_at: string updated_at: string
web?: { web?: {
seo_metadata?: { seo_metadata?: SeoMetadata
noindex?: boolean | null
sitemap?: {
change_frequency: SitemapEntry["changeFrequency"]
priority: SitemapEntry["priority"]
} | null
}
} }
// SEO Filters on destination pages
slug?: string
seo_filters?: SeoFilter[]
} }
export interface SyncItem { export interface SyncItem {
type: string type: string
content_type_uid: string
data: SyncItemData data: SyncItemData
} }
export interface SyncResponse extends Omit<SyncResult, "items"> { export interface SyncResponse extends Omit<SyncResult, "items"> {

View File

@@ -2,11 +2,12 @@ import { getStore } from "@netlify/blobs"
import { env } from "@/env/server" 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 branch = env.CMS_BRANCH
const environment = env.CMS_ENVIRONMENT const environment = env.CMS_ENVIRONMENT
const entriesKey = `${environment}/${branch}/entries` const entriesKey = `${environment}/${branch}/entries`
const hotelFiltersKey = `${environment}/${branch}/hotelFilters`
const syncTokenKey = `${environment}/${branch}/syncToken` const syncTokenKey = `${environment}/${branch}/syncToken`
const sitemapDataKey = `${environment}/${branch}/sitemapData` const sitemapDataKey = `${environment}/${branch}/sitemapData`
const lastUpdatedKey = `${environment}/${branch}/lastUpdated` 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 // 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. // 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() { function store() {
return getStore("sitemap") return getStore("sitemap")
} }
@@ -22,6 +30,12 @@ export async function saveEntries(entries: SyncItem[]) {
await store().setJSON(entriesKey, entries) await store().setJSON(entriesKey, entries)
} }
export async function saveHotelFilters(
hotelFilters: Record<string, HotelFilterEntry[]>
) {
await store().setJSON(hotelFiltersKey, hotelFilters)
}
export async function saveSitemapData(sitemapData: SitemapData) { export async function saveSitemapData(sitemapData: SitemapData) {
await store().setJSON(sitemapDataKey, sitemapData) await store().setJSON(sitemapDataKey, sitemapData)
} }