Files
web/apps/scandic-web/app/api/web/sitemap/utils.ts
Erik Tiekstra 0bcde9f74f feat(BOOK-58): Added destination filter pages to sitemap
Approved-by: Linus Flood
2025-09-30 13:17:14 +00:00

294 lines
7.7 KiB
TypeScript

import { type Lang } from "@scandic-hotels/common/constants/language"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { removeTrailingSlash } from "@scandic-hotels/common/utils/url"
import { env } from "@/env/server"
import type {
HotelFilterEntries,
HotelFilterEntry,
SeoFilter,
SyncItem,
} from "@/types/sitemap"
export function mergeEntries(
currentEntries: SyncItem[],
newEntries: SyncItem[]
) {
const entriesMergeCounter = createCounter("sitemap", "entries.merge")
const metricsEntriesMerge = entriesMergeCounter.init({
currentEntriesCount: currentEntries.length,
newEntriesCount: newEntries.length,
})
metricsEntriesMerge.start()
const entries = [...currentEntries]
newEntries.forEach((entry) => {
if (!entry?.data?.uid || !entry?.data?.locale) {
metricsEntriesMerge.dataError(
`Invalid entry data, missing uid or locale`,
{ entry }
)
return
}
const index = entries.findIndex(({ data }) => {
if (!data) {
metricsEntriesMerge.dataError(`Data is null or undefined,`)
return false
}
return data.uid === entry.data.uid && data.locale === entry.data.locale
})
if (index > -1) {
entries[index] = entry
} else {
entries.push(entry)
}
})
metricsEntriesMerge.success({
entriesCount: entries.length,
})
return entries
}
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,
})
metricsEntriesTransform.start()
const filteredEntries = filterEntriesToSitemapEntries(entries)
const entriesByUid = groupEntriesByUid(filteredEntries)
const sitemapEntries = Object.entries(entriesByUid)
.map(([_, entries]) => mapEntriesToSitemapEntries(entries, hotelFilters))
.flat()
metricsEntriesTransform.success({
entriesCount: entries.length,
sitemapEntriesCount: sitemapEntries.length,
})
return sitemapEntries
}
function filterEntriesToSitemapEntries(entries: SyncItem[]) {
return entries.filter((entry: SyncItem) => {
const shouldIndex = !entry.data.web?.seo_metadata?.noindex
return !!(entry.type === "entry_published" && entry.data.url && shouldIndex)
})
}
// We group the entries by UID because Contentstack has the same `uid` for an
// entry regardless of locale. We want to display each locale as an alternate
// in the sitemap, therefore we group them here by `uid`.
function groupEntriesByUid(entries: SyncItem[]) {
return entries.reduce<Record<string, SyncItem[]>>((acc, entry) => {
const uid = entry.data.uid
if (!acc[uid]) {
acc[uid] = []
}
acc[uid].push(entry)
return acc
}, {})
}
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}`
),
lastModified: currentEntry.data.updated_at,
changeFrequency:
currentEntry.data.web?.seo_metadata?.sitemap?.change_frequency ?? "daily",
priority: currentEntry.data.web?.seo_metadata?.sitemap?.priority ?? 0.5,
alternates,
}))
return [...pageEntries, ...seoFilterEntries]
}
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}`
)
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
},
{}
)
}