feat(BOOK-58): Added destination filter pages to sitemap
Approved-by: Linus Flood
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"> {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user