diff --git a/data/redirects.csv b/data/redirects.csv index 084145e..50d6cb0 100644 --- a/data/redirects.csv +++ b/data/redirects.csv @@ -9,5 +9,11 @@ from,to /looking-for-a-pet-friendly-airbnb-heres-your-ultimate-guide/,/pet-friendly-airbnb-guide/ /pet-friendly-airbnbs-sniff-out-the-best-spots-for-a-pawsome-vacation/,/pet-friendly-airbnbs/ /youll-lose-thousands-hosting-on-airbnb-if-you-do-this%ef%bf%bc/,/youll-lose-thousands-hosting-on-airbnb-if-you-do-this/ +/at-ease-rentals-with-anthony-gantt-underrepresented-hosts-series/,/at-ease-rentals-with-anthony-gantt-under-represented-hosts-series/ +/blog-post-guest-review-scores-touchstay/,/how-to-use-communication-to-elevate-your-guest-review-scores/ +/short-term-rental-market-analysis/,/mastering-the-short-term-rental-game-a-comprehensive-market-analysis-guide/ +/collecting-a-vacation-rental-security-deposit/,/the-importance-of-collecting-a-vacation-rental-security-deposit/ +/blog-posts-airbnb-winter-release-2022/,/your-guide-to-airbnbs-2022-winter-release/ +/the-hichee-blog/,/ /hichee-blog-for-hosts/page/2,/hichee-blog-for-hosts/ /hichee-blog-for-hosts/page/2/,/hichee-blog-for-hosts/ diff --git a/data/redirects.extra.csv b/data/redirects.extra.csv index ff61df2..72bae1e 100644 --- a/data/redirects.extra.csv +++ b/data/redirects.extra.csv @@ -6,6 +6,12 @@ from,to /harry-potter-themed-airbnbs-our-top-9-for-a-magical-stay/,/harry-potter-themed-airbnbs/ /looking-for-a-pet-friendly-airbnb-heres-your-ultimate-guide/,/pet-friendly-airbnb-guide/ /pet-friendly-airbnbs-sniff-out-the-best-spots-for-a-pawsome-vacation/,/pet-friendly-airbnbs/ +/at-ease-rentals-with-anthony-gantt-underrepresented-hosts-series/,/at-ease-rentals-with-anthony-gantt-under-represented-hosts-series/ +/blog-post-guest-review-scores-touchstay/,/how-to-use-communication-to-elevate-your-guest-review-scores/ +/short-term-rental-market-analysis/,/mastering-the-short-term-rental-game-a-comprehensive-market-analysis-guide/ +/collecting-a-vacation-rental-security-deposit/,/the-importance-of-collecting-a-vacation-rental-security-deposit/ +/blog-posts-airbnb-winter-release-2022/,/your-guide-to-airbnbs-2022-winter-release/ +/the-hichee-blog/,/ /career/,https://hichee.com/careers /hosts/,https://hichee.com/hosts /listing-verification/,https://hichee.com/listing-verification diff --git a/package.json b/package.json index dd99b55..4dae2dd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "migrate:download:rest": "node scripts/export-wp-rest.mjs", "migrate:convert:wxr": "node scripts/convert-wxr-to-content.mjs", "migrate:convert:rest": "node scripts/convert-rest-to-content.mjs", + "migrate:authors:map": "node scripts/build-author-post-paths.mjs", "migrate:related:legacy": "node scripts/extract-legacy-related-posts.mjs", "migrate:media:manifest": "node scripts/build-media-manifest.mjs", "migrate:media:manifest:site": "node scripts/build-wp-content-manifest.mjs", diff --git a/public/_redirects b/public/_redirects index c6bfec2..57a353e 100644 --- a/public/_redirects +++ b/public/_redirects @@ -13,6 +13,12 @@ /pet-friendly-airbnbs-sniff-out-the-best-spots-for-a-pawsome-vacation/ /pet-friendly-airbnbs/ 301 /youll-lose-thousands-hosting-on-airbnb-if-you-do-this%*/ /youll-lose-thousands-hosting-on-airbnb-if-you-do-this/ 301 /youll-lose-thousands-hosting-on-airbnb-if-you-do-this%ef%bf%bc/ /youll-lose-thousands-hosting-on-airbnb-if-you-do-this/ 301 +/at-ease-rentals-with-anthony-gantt-underrepresented-hosts-series/ /at-ease-rentals-with-anthony-gantt-under-represented-hosts-series/ 301 +/blog-post-guest-review-scores-touchstay/ /how-to-use-communication-to-elevate-your-guest-review-scores/ 301 +/short-term-rental-market-analysis/ /mastering-the-short-term-rental-game-a-comprehensive-market-analysis-guide/ 301 +/collecting-a-vacation-rental-security-deposit/ /the-importance-of-collecting-a-vacation-rental-security-deposit/ 301 +/blog-posts-airbnb-winter-release-2022/ /your-guide-to-airbnbs-2022-winter-release/ 301 +/the-hichee-blog/ / 301 /career https://hichee.com/careers 301 /career/ https://hichee.com/careers 301 /hosts https://hichee.com/hosts 301 diff --git a/scripts/build-author-post-paths.mjs b/scripts/build-author-post-paths.mjs new file mode 100644 index 0000000..bf8c38b --- /dev/null +++ b/scripts/build-author-post-paths.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import matter from 'gray-matter'; + +const args = parseArgs(process.argv.slice(2)); +const outPath = path.resolve(args.out ?? 'src/data/author-post-paths.json'); +const postsDir = path.resolve(args.postsDir ?? 'src/content/posts'); +const wxrDir = path.resolve(args.wxrDir ?? 'data/raw'); +const routeAliasesPath = new URL('../src/data/route-aliases.json', import.meta.url); +const routeAliases = JSON.parse(await fs.readFile(routeAliasesPath, 'utf8')); + +const AUTHOR_ALIASES = { + Tiffany: 'tiffany', + 'alan@rentmoreweeks.com': 'alan', + 'Our Discount Desk': 'our-discount-desk', + 'Our Travel Reporter': 'our-travel-reporter', +}; + +const ROUTE_ALIASES = new Map(Object.entries(routeAliases)); + +const localPosts = await loadLocalPosts(postsDir); +const authorPathMap = await buildAuthorPathMap(wxrDir, localPosts); + +await fs.mkdir(path.dirname(outPath), { recursive: true }); +await fs.writeFile(outPath, `${JSON.stringify(authorPathMap, null, 2)}\n`); + +console.log(`Author post paths written: ${outPath}`); +for (const [slug, routes] of Object.entries(authorPathMap)) { + console.log(`- ${slug}: ${routes.length}`); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) continue; + const [key, inlineValue] = arg.split('='); + const name = key.slice(2); + if (inlineValue !== undefined) { + out[name] = inlineValue; + continue; + } + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + out[name] = true; + } else { + out[name] = next; + i += 1; + } + } + return out; +} + +async function loadLocalPosts(rootDir) { + const files = (await fs.readdir(rootDir)) + .filter((entry) => entry.endsWith('.md')) + .map((entry) => path.join(rootDir, entry)); + + const byWordpressId = new Map(); + const byRoute = new Map(); + + for (const filePath of files) { + const raw = await fs.readFile(filePath, 'utf8'); + const { data } = matter(raw); + if (data.status !== 'publish' || data.draft === true) continue; + + const wordpressId = String(data.wordpressId ?? '').trim(); + const route = normalizeRoutePath(data.path); + const dateValue = toTimestamp(data.date); + if (!route || !dateValue) continue; + + const record = { + wordpressId, + route, + date: dateValue, + }; + + if (wordpressId) byWordpressId.set(wordpressId, record); + byRoute.set(route, record); + } + + return { byWordpressId, byRoute }; +} + +async function buildAuthorPathMap(wxrDir, localPosts) { + const files = (await fs.readdir(wxrDir)) + .filter((entry) => /^wordpress-export-posts-\d{4}\.xml$/.test(entry)) + .map((entry) => path.join(wxrDir, entry)) + .sort(); + + const pathsBySlug = new Map(Object.values(AUTHOR_ALIASES).map((slug) => [slug, new Map()])); + + for (const filePath of files) { + const raw = await fs.readFile(filePath, 'utf8'); + for (const item of iterateItems(raw)) { + const slug = AUTHOR_ALIASES[item.creator]; + if (!slug) continue; + if (item.postType !== 'post' || item.status !== 'publish') continue; + + const sourceRoute = normalizeRoutePath(item.link); + const preferred = localPosts.byWordpressId.get(item.wordpressId); + const fallback = localPosts.byRoute.get(sourceRoute); + const aliasTarget = ROUTE_ALIASES.get(sourceRoute) ?? ''; + const aliasResolved = aliasTarget ? localPosts.byRoute.get(aliasTarget) : null; + const resolved = preferred ?? fallback ?? aliasResolved; + if (!resolved) continue; + + // Preserve the exported alias path when the WXR item only resolves via ROUTE_ALIASES + // so author archive pagination can match WordPress while reusing the canonical local post. + const recordedRoute = + preferred || fallback || !aliasResolved ? resolved.route : sourceRoute; + + pathsBySlug.get(slug).set(recordedRoute, resolved.date); + } + } + + return Object.fromEntries( + [...pathsBySlug.entries()].map(([slug, routes]) => { + const orderedRoutes = [...routes.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([route]) => route); + return [slug, orderedRoutes]; + }) + ); +} + +function *iterateItems(xml) { + const itemRegex = /([\s\S]*?)<\/item>/g; + for (const match of xml.matchAll(itemRegex)) { + const item = match[1]; + yield { + creator: decodeXml(extractCdata(item, 'dc:creator')), + status: extractCdata(item, 'wp:status'), + postType: extractCdata(item, 'wp:post_type'), + wordpressId: extractTag(item, 'wp:post_id'), + link: decodeXml(extractTag(item, 'link')), + }; + } +} + +function extractCdata(source, tagName) { + const match = source.match(new RegExp(`<${escapeRegExp(tagName)}><\\/${escapeRegExp(tagName)}>`, 'i')); + if (match) return match[1].trim(); + return extractTag(source, tagName); +} + +function extractTag(source, tagName) { + const match = source.match(new RegExp(`<${escapeRegExp(tagName)}>([\\s\\S]*?)<\\/${escapeRegExp(tagName)}>`, 'i')); + return match ? match[1].trim() : ''; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function decodeXml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(/–/g, '–') + .replace(/’/g, '’') + .replace(/“/g, '“') + .replace(/”/g, '”') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +function normalizeRoutePath(value) { + const raw = String(value || '') + .replace(/https?:\/\/blog\.hichee\.com/i, '') + .replace(/%ef%bf%bc/gi, '') + .replace(/\uFFFC/g, '') + .trim(); + + if (!raw) return ''; + + const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`; + return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/`; +} + +function toTimestamp(value) { + if (value instanceof Date) return value.getTime(); + const parsed = Date.parse(String(value || '')); + return Number.isFinite(parsed) ? parsed : 0; +} diff --git a/scripts/build-wp-content-manifest.mjs b/scripts/build-wp-content-manifest.mjs index 4b620ca..b96263b 100644 --- a/scripts/build-wp-content-manifest.mjs +++ b/scripts/build-wp-content-manifest.mjs @@ -21,7 +21,7 @@ walk(rootDir, (file) => { return; } - const matches = text.match(/(?:https?:\/\/(?:blog|newblog)\.hichee\.com)?\/wp-content\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+/gi) ?? []; + const matches = text.match(/(?:https?:\/\/(?:blog|newblog)\.hichee\.com)?\/wp-content\/[^\s"'<>),?#]+/gi) ?? []; for (const match of matches) { keys.add(normalizeKey(match)); } diff --git a/src/components/PaginationNav.astro b/src/components/PaginationNav.astro new file mode 100644 index 0000000..997de23 --- /dev/null +++ b/src/components/PaginationNav.astro @@ -0,0 +1,99 @@ +--- +interface Props { + basePath: string; + currentPage: number; + totalPages: number; + ariaLabel?: string; +} + +const { basePath, currentPage, totalPages, ariaLabel = 'Pagination' } = Astro.props; +const items = totalPages > 1 ? buildItems(currentPage, totalPages) : []; +const previousHref = totalPages > 1 && currentPage > 1 ? pageHref(basePath, currentPage - 1) : null; +const nextHref = totalPages > 1 && currentPage < totalPages ? pageHref(basePath, currentPage + 1) : null; +const previousPage = currentPage - 1; +const nextPage = currentPage + 1; + +function buildItems(current: number, total: number) { + const pages = new Set([1, total, current - 1, current, current + 1]); + if (current <= 3) { + pages.add(2); + pages.add(3); + } + if (current >= total - 2) { + pages.add(total - 1); + pages.add(total - 2); + } + + const ordered = [...pages] + .filter((page) => page >= 1 && page <= total) + .sort((a, b) => a - b); + + const items = []; + let previous = 0; + for (const page of ordered) { + if (previous && page - previous > 1) { + items.push({ type: 'ellipsis', key: `ellipsis-${previous}-${page}` }); + } + items.push({ type: 'page', key: `page-${page}`, page }); + previous = page; + } + return items; +} + +function pageHref(rootPath: string, page: number) { + if (page <= 1) return rootPath; + return `${rootPath}page/${page}/`; +} +--- + +{ + totalPages > 1 && ( + + ) +} diff --git a/src/data/author-post-paths.json b/src/data/author-post-paths.json index 6a50a50..1f0bd96 100644 --- a/src/data/author-post-paths.json +++ b/src/data/author-post-paths.json @@ -1,286 +1,596 @@ { - "alan": [ - "/20-free-tools-that-make-your-hospitality-website-rock/", - "/5-steps-to-clean-up-and-organize-your-vacation-rental-website/", - "/7-short-term-rental-myths-debunked/", - "/airbnb-alternatives-2024/", - "/airbnb-alternatives-airbnb-v-booking-com-decoding-the-best-deals/", - "/airbnb-alternatives-airbnb-v-vrbo-a-guide-for-savvy-travelers/", - "/airbnb-alternatives-hipcamp-guide-discovering-the-best-hidden-gems/", - "/airbnb-bookings-hack-get-20-more-bookings-starting-today/", - "/airbnb-ceo-vows-to-tackle-runaway-cleaning-fees/", - "/airbnb-gift-cards-all-you-need-to-know-about-these-perfect-gifts/", - "/airbnb-hosts-is-it-time-to-review-your-views-on-reviews/", - "/airbnb-or-vrbo-where-should-i-list-my-property/", - "/airbnb-rental-arbitrage-and-how-to-succeed-at-it/", - "/airbnbs-10-fold-increase-in-host-cancellation-fees-could-benefit-guests/", - "/airbnbs-latest-update-sparks-outrage-could-this-be-the-end-for-many-hosts/", - "/amazing-disney-themed-vacation-rental-in-kissimmee-and-how-to-book-it-for-less/", - "/amazon-travel-essentials-you-need-in-2022-my-top-10-travel-products/", - "/best-airbnb-smart-home-devices-part-3-moneymaking-wifi/", - "/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022%ef%bf%bc/", - "/build-a-backyard-pickleball-court-for-more-bookings/", - "/check-out-this-wonderful-treehouse-and-book-it-for-less/", - "/does-social-media-drive-direct-bookings-for-your-vacation-rental/", - "/dont-get-scammed-on-airbnb-find-out-the-ugly-truth-and-save-big-money/", - "/dont-give-up-the-daydream-no-1/", - "/dont-give-up-the-daydream-no-2/", - "/dont-give-up-the-daydream-no-3/", - "/dont-give-up-the-daydream-no-4/", - "/dont-give-up-the-daydream/", - "/fair-use/", - "/free-airbnb-welcome-book-template/", - "/free-short-term-rental-agreement-templates-word-pdf/", - "/gatlinburg-4-nights-save-192/", - "/getting-more-direct-bookings-the-value-of-repeat-guests/", - "/holiday-let-emails-messages-to-send-to-guests/", - "/hot-tub-photography-for-airbnb-hosts/", - "/how-to-furnish-your-airbnb-like-a-pro-and-a-few-shopping-secrets/", - "/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo%ef%bf%bc/", - "/labor-day-weekend-2022-vacation-rental-market-data-forecast/", - "/lowest-price-vacation-rentals-magic-search-without-witchcraft/", - "/new-york-city-3-nights-save-474/", - "/pack-up-and-go-with-these-15-travel-hacks-and-more-diy-ideas-by-crafty-panda/", - "/pet-friendly-airbnb-guide/", - "/smart-home-devices-for-your-airbnb-a-complete-guide/", - "/smart-home-monitoring-for-your-airbnb-a-complete-guide-pt-2/", - "/the-10-best-things-to-do-in-miami-a-video-guide/", - "/the-coming-collapse-of-airbnb-short-term-rentals/", - "/the-meaning-behind-hichee/", - "/the-pros-and-cons-of-booking-direct-vacation-rental-secrets/", - "/the-short-term-rental-hosts-guide-on-how-to-survive-the-recession/", - "/the-ultimate-guide-to-selecting-the-perfect-travel-insurance-for-your-2023-adventure/", - "/today-is-book-direct-day-but-does-it-really-work/", - "/top-10-travel-tips-tricks-from-a-flight-attendant/", - "/travel-hack-airbnb-5-times-the-price-i-save-4100-in-1-minute/", - "/travel-quotes-101/", - "/travel-quotes-102/", - "/travel-quotes-103/", - "/travel-quotes-104/", - "/travel-quotes-105/", - "/travel-quotes-106/", - "/travel-quotes-107/", - "/travel-quotes-108/", - "/travel-quotes-109/", - "/vacation-rental-damage-deposits-all-you-need-to-know-including-tips-from-hosts/", - "/vacation-rental-deals-st-barts-4-guests-july-3rd-july-16th/", - "/vacation-rental-price-comparison-watch-as-i-save-5836-on-this-airbnb-in-under-1-minute/", - "/vacation-rental-website-builders-what-are-your-best-options/", - "/vacation-rentals-best-price-and-100-peace-of-mind-every-time/", - "/vacation-rentals-book-direct-with-the-owner-and-save-heres-how/", - "/vacation-rentals-cancellation-policies-compared-all-you-need-to-know/", - "/vrbo-and-airbnb-youll-never-guess-what-vrbo-are-doing/", - "/want-to-really-get-away-heres-some-expert-advice-for-digital-nomad-accommodations/", - "/were-all-in-the-airbnb-business-now-part-2/", - "/where-am-i-1/", - "/where-am-i-2/", - "/where-am-i-3/", - "/why-youll-be-750-times-better-off-running-an-airbnb-than-a-vacation-rental-or-holiday-let/", - "/youll-lose-thousands-hosting-on-airbnb-if-you-do-this%ef%bf%bc/" - ], - "our-discount-desk": [ - "/35-secrets-to-save-money-on-your-next-vacation/", - "/arizona-4-nights-save-108/", - "/arizona-7-nights-save-295/", - "/california-7-nights-save-1915/", - "/cancun-7-nights-save-3927/", - "/catalonia-6-nights-save-121/", - "/costa-rica-7-nights-save-110/", - "/florida-5-nights-save-1861/", - "/indiana-5-nights-save-661/", - "/north-carolina-4-nights-save-41/", - "/quebec-3-nights-save-217/", - "/saint-lucia-7-nights-save-484/" - ], - "our-travel-reporter": [ - "/airbnb-cabins-get-close-to-nature-in-these-hidden-gems/", - "/airbnb-treehouses/", - "/property-spotlight-a-delightful-a-frame-cabin-best-price-revealed/", - "/property-spotlight-stunning-beach-house-and-how-to-book-it-for-less/", - "/see-how-to-save-over-100-a-night-on-this-wonderful-treehouse-in-georgia/", - "/top-7-best-vacation-rental-channel-managers-in-2022/", - "/top-9-website-builders-property-managers-use-for-their-vacation-rental/" - ], "tiffany": [ - "/9-most-expensive-airbnb-luxe-properties-in-the-us/", - "/9-of-the-best-frozen-themed-airbnbs-in-the-us-love-is-an-open-door/", - "/9-of-the-best-gites-in-france/", - "/9-of-the-most-expensive-airbnb-getaways-in-europe/", - "/a-peek-inside-the-most-instagrammable-airbnb-in-miami/", - "/a-romantic-airbnb-in-california-relationshipgoals/", + "/pet-friendly-airbnbs/", + "/top-eerie-escapes-to-stay-for-halloween/", + "/hocking-hills-airbnbs-with-hot-tubs/", + "/oklahoma-airbnbs-with-hot-tubs/", + "/the-redditors-guide-to-airbnb-hosting-best-communities-for-advice-and-support/", + "/best-airbnbs-destin/", + "/best-marco-island-airbnbs/", + "/best-airbnbs-fort-lauderdale/", + "/best-chrome-extensions-for-travelers/", + "/barbie-airbnb-in-california/", + "/anna-maria-island-rental-near-holmes-beach/", + "/airbnb-key-west/", + "/estes-park-cabins-with-hot-tubs/", + "/price-check-this-orlando-airbnb-with-pool-and-save-money/", + "/barbie-themed-airbnbs/", "/a-romantic-airbnb-with-hot-tub/", - "/a-trendy-miami-airbnb-with-panoramic-morning-views/", - "/a-wizards-retreat-discovering-the-magic-of-a-harry-potter-themed-airbnb/", - "/adult-themed-airbnb-in-florida-do-you-dare-go-down-the-rabbit-hole/", - "/adult-themed-airbnbs-10-steamy-escapes-for-the-grown-ups/", "/airbnb-anna-maria-island/", - "/airbnb-beach-house-retreat-a-sun-kissed-escape/", - "/airbnb-condo-in-miami-experience-the-high-life/", + "/romantic-getaways/", "/airbnb-florida/", - "/airbnb-jacksonville/", - "/airbnb-key-west/", - "/airbnb-miami/", + "/airbnb-condo-in-miami-experience-the-high-life/", + "/miami-airbnb-with-pool-dive-into-your-dream-vacation/", + "/waterfront-airbnb-tampa-fl-family-friendly-find-with-a-view/", + "/romantic-airbnb-new-mexico-glamping-at-el-mistico-ranch/", + "/orlando-fl-airbnb-your-ticket-to-paradise-in-the-heart-of-orlando/", + "/a-peek-inside-the-most-instagrammable-airbnb-in-miami/", "/airbnb-orlando-near-disney-a-luxe-shipping-container/", - "/airbnb-orlando/", + "/tampa-airbnb-with-hot-tub-treat-yourself-to-a-romantic-break/", + "/a-trendy-miami-airbnb-with-panoramic-morning-views/", + "/airbnb-jacksonville/", + "/a-romantic-airbnb-in-california-relationshipgoals/", "/airbnb-tampa/", - "/airbnb-wedding-venue-in-alabama-a-secret-gem-for-your-special-day/", - "/airbnb-wedding-venue-in-costa-rica-discover-your-dream-destination/", - "/airbnb-wedding-venue-in-montana-a-luxurious-hideaway-for-your-i-dos/", + "/best-secluded-romantic-airbnb-in-california/", + "/airbnb-orlando/", + "/airbnb-beach-house-retreat-a-sun-kissed-escape/", + "/romantic-airbnbs/", + "/pet-friendly-airbnb-a-furry-friendly-find-for-your-next-adventure/", + "/disney-themed-airbnb-for-your-little-superheros/", + "/pet-friendly-airbnb-in-north-carolina-where-tails-wag-and-hearts-warm/", + "/airbnb-miami/", + "/adult-themed-airbnb-in-florida-do-you-dare-go-down-the-rabbit-hole/", + "/pet-friendly-airbnb-in-montana-with-paw-some-perks/", + "/disney-themed-airbnb-in-kissimmee-florida-with-millennium-falcon-bed/", + "/gold-medal-accommodations-your-ultimate-guide-to-where-to-stay-during-the-paris-olympics-2024/", + "/disney-themed-airbnb-in-kissimmee-where-dreams-come-true/", + "/an-airbnb-treehouse-adventure-in-georgia-unwind-in-natures-embrace/", + "/an-airbnb-treehouse-experience-ultimate-getaway-in-the-sky/", + "/disney-themed-airbnb-in-florida-a-magical-stay-for-disney-fans/", + "/airbnb-wedding-venue-in-washington-a-romantic-retreat-for-your-big-day/", + "/adult-themed-airbnbs-10-steamy-escapes-for-the-grown-ups/", "/airbnb-wedding-venue-in-new-mexico-an-iconic-spot-for-saying-yes/", + "/tie-the-knot-at-this-fancy-airbnb-wedding-venue-in-florida/", + "/airbnb-wedding-venue-in-montana-a-luxurious-hideaway-for-your-i-dos/", + "/airbnb-wedding-venue-in-alabama-a-secret-gem-for-your-special-day/", "/airbnb-wedding-venue-in-oregon-a-breathtaking-setting-for-lovebirds/", - "/airbnb-wedding-venue-in-washington-a-romantic-retreat-for-your-big-day/", + "/unique-airbnb-wedding-venue-to-tie-the-knot/", + "/airbnb-wedding-venue-in-costa-rica-discover-your-dream-destination/", + "/harry-potter-airbnb-in-massachusetts/", + "/harry-potter-airbnb-fantasy-where-wizardry-and-comfort-unite/", + "/harry-potter-airbnb-journey-rent-harry-potters-childhood-home/", + "/harry-potter-airbnb-dream-book-your-stay-now/", + "/harry-potter-airbnb-adventure-stay-in-a-wizarding-world-wonderland/", + "/harry-potter-airbnb-magic-experience-the-ultimate-wizards-retreat/", "/airbnbs-with-pickleball-courts-9-of-the-best-game-on/", - "/an-airbnb-treehouse-adventure-in-georgia-unwind-in-natures-embrace/", - "/an-airbnb-treehouse-experience-ultimate-getaway-in-the-sky/", - "/anna-maria-island-rental-near-holmes-beach/", - "/barbie-airbnb-in-california/", - "/barbie-themed-airbnbs/", - "/barcelona-catalonia-7-nights-save-780/", - "/barcelona-catalunya-8-nights-save-176/", - "/bellagio-lombardy-3-nights-save-795/", - "/best-airbnbs-destin/", - "/best-airbnbs-fort-lauderdale/", - "/best-chrome-extensions-for-travelers/", - "/best-marco-island-airbnbs/", - "/best-secluded-romantic-airbnb-in-california/", + "/muggle-to-magician-stay-in-a-harry-potter-themed-airbnb/", + "/the-spellbinding-stay-experience-a-harry-potter-airbnb-adventure/", + "/a-wizards-retreat-discovering-the-magic-of-a-harry-potter-themed-airbnb/", + "/linkedin-groups-for-airbnb-hosts-the-ultimate-network-for-success/", + "/the-best-travel-accessories-for-2024-a-must-have-guide-for-globetrotters/", + "/property-spotlight-unveiling-the-ultimate-castle-rental-experience/", + "/unique-airbnb-stays/", + "/property-spotlight-amazing-glamping-rental/", + "/property-spotlight-a-cool-tiny-house-with-big-savings/", "/best-travel-apps-for-stress-free-journeys-in-2024/", "/creating-airbnb-floor-plans-the-best-apps-for-the-job/", - "/disney-themed-airbnb-for-your-little-superheros/", - "/disney-themed-airbnb-in-florida-a-magical-stay-for-disney-fans/", - "/disney-themed-airbnb-in-kissimmee-florida-with-millennium-falcon-bed/", - "/disney-themed-airbnb-in-kissimmee-where-dreams-come-true/", - "/dont-give-up-the-daydream-no-23-2/", - "/dont-give-up-the-daydream-no-23/", - "/dont-give-up-the-daydream-no-24/", - "/dont-give-up-the-daydream-no-25/", - "/dubai-united-arab-emirates-5-nights-save-196/", - "/dubai-united-arab-emirates-6-nights-save-235/", - "/dublin-ireland-8-nights-save-161/", - "/estes-park-cabins-with-hot-tubs/", + "/passport-to-inspiration-a-deep-dive-into-the-best-travel-blogs-on-the-web/", "/facebook-groups-for-airbnb-hosts-the-ultimate-list/", - "/firenze-toscana-3-nights-save-428/", - "/firenze-toscana-7-nights-save-1037/", - "/firenze-toscana-8-nights-save-410/", - "/fuengirola-spain-7-nights-save-121/", - "/gold-medal-accommodations-your-ultimate-guide-to-where-to-stay-during-the-paris-olympics-2024/", + "/9-of-the-most-expensive-airbnb-getaways-in-europe/", + "/the-best-airbnb-wedding-venues-in-the-us/", + "/the-most-expensive-airbnbs-in-the-caribbean/", + "/kissimmee-florida-7-nights-save-537/", "/grosenbrode-schleswig-holstein-6-nights-save-120/", + "/roma-citta-metropolitana-di-roma-6-nights-save-120/", + "/reykjavik-iceland-6-nights-save-248/", + "/zarbo-di-mare-sicilia-7-nights-save-259/", + "/wilen-sarnen-obwalden-7-nights-save-324/", + "/mailhoc-tarn-6-nights-save-991/", + "/vogar-iceland-6-nights-save-172/", + "/valtournenche-valle-daosta-8-nights-save-155/", + "/valenca-do-douro-viseu-3-nights-save-63/", + "/venice-veneto-italy-9-nights-save-483/", + "/9-of-the-best-gites-in-france/", + "/twickenham-england-6-nights-save-462/", + "/truckee-california-10-nights-save-7117/", + "/roma-citta-metropolitana-di-roma-6-nights-save-374/", + "/rome-lazio-3-nights-save-127/", + "/sussex-inlet-australia-5-nights-save-169/", + "/swampscott-massachusetts-4-nights-save-211/", + "/torcigliano-tuscany-7-nights-save-1294/", + "/tawonga-south-australia-5-nights-save-185/", + "/tambon-kammala-phuket-province-8-nights-save-714/", + "/taormina-sicilia-4-nights-save-268/", + "/tenerife-spain-7-nights-save-675/", + "/surfers-paradise-australia-7-nights-save-108/", + "/south-kuta-bali-9-nights-save-1612/", + "/saint-martin-wallis-14-nights-save-1483/", + "/split-central-dalmatia-5-nights-save-116/", + "/soller-balearic-islands-7-nights-save-637/", + "/sesimbra-setubal-7-nights-save-66/", + "/shute-harbour-queensland-14-nights-save-132/", + "/sainte-maxime-provence-alpes-cote-dazur-6-nights-save-450/", + "/rome-lazio-4-nights-save-318/", + "/fuengirola-spain-7-nights-save-121/", + "/roma-lazio-6-nights-save-431/", + "/firenze-toscana-3-nights-save-428/", + "/dubai-united-arab-emirates-5-nights-save-196/", + "/dubai-united-arab-emirates-6-nights-save-235/", + "/ko-samui-district-surat-thani-8-nights-save-84/", + "/kihei-hawaii-8-nights-save-182/", "/hamilton-island-australia-10-nights-save-81/", - "/hardwicke-bay-south-australia-8-nights-save-56/", - "/harry-potter-airbnb-adventure-stay-in-a-wizarding-world-wonderland/", - "/harry-potter-airbnb-dream-book-your-stay-now/", - "/harry-potter-airbnb-fantasy-where-wizardry-and-comfort-unite/", - "/harry-potter-airbnb-in-massachusetts/", - "/harry-potter-airbnb-journey-rent-harry-potters-childhood-home/", - "/harry-potter-airbnb-magic-experience-the-ultimate-wizards-retreat/", - "/harry-potter-themed-airbnbs/", "/headford-county-galway-5-nights-save-508/", - "/hocking-hills-airbnbs-with-hot-tubs/", - "/hua-hin-prachuap-khiri-khan-7-nights-save-95/", "/ionian-islands-peloponnese-6-nights-save-382/", - "/kamakura-kanagawa-japan-8-nights-save-158/", - "/kapalua-hawaii-7-nights-save-471/", - "/kathisma-beach-greece-7-nights-save-182/", "/kihei-hawaii-5-nights-save-64/", - "/kihei-hawaii-8-nights-save-182/", - "/kissimmee-florida-7-nights-save-537/", - "/kissimmee-florida-8-nights-save-583/", - "/ko-samui-district-surat-thani-8-nights-save-84/", + "/hua-hin-prachuap-khiri-khan-7-nights-save-95/", + "/lucolena-florence-7-nights-save-486/", + "/paris-ile-de-france-7-nights-save-141/", + "/london-england-7-nights-save-64/", + "/poggibonsi-toscana-7-nights-save-239/", + "/oia-aegean-5-nights-save-1611/", + "/megalochori-aegean-5-nights-save-183/", + "/phuket-thailand-8-nights-save-380/", + "/malay-western-visayas-5-nights-save-120/", + "/manarola-liguria-9-nights-save-683/", + "/london-england-9-nights-save-343/", + "/kyoto-city-kyoto-7-nights-save-493/", "/ko-samui-thailand-8-nights-save-270/", "/koh-samui-tambon-bophut-surat-thani-6-nights-save-530/", - "/kuta-bali-4-nights-save-8722/", + "/london-england-2-nights-save-129/", + "/9-of-the-best-frozen-themed-airbnbs-in-the-us-love-is-an-open-door/", + "/dont-give-up-the-daydream-no-25/", + "/port-douglas-australia-9-nights-save-1287/", + "/praiano-campania-7-nights-save-86/", + "/perledo-lecco-5-nights-save-96/", + "/where-am-i-29/", "/kuta-bali-7-nights-save-2235/", - "/kyoto-city-kyoto-7-nights-save-493/", - "/les-houches-auvergne-rhone-alpes-8-nights-save-433/", - "/linkedin-groups-for-airbnb-hosts-the-ultimate-network-for-success/", + "/9-most-expensive-airbnb-luxe-properties-in-the-us/", + "/kathisma-beach-greece-7-nights-save-182/", + "/kissimmee-florida-8-nights-save-583/", + "/travel-quotes-123/", + "/kapalua-hawaii-7-nights-save-471/", + "/dont-give-up-the-daydream-no-24/", + "/paris-ile-de-france-8-nights-save-282/", "/lombardia-italy-7-nights-save-2507/", - "/london-england-2-nights-save-129/", - "/london-england-7-nights-save-64/", - "/london-england-9-nights-save-343/", + "/kuta-bali-4-nights-save-8722/", + "/where-am-i-28/", + "/les-houches-auvergne-rhone-alpes-8-nights-save-433/", + "/montreuil-aux-lions-nord-pas-de-calais-picardie-6-nights-save-108/", + "/paris-ile-de-france-5-nights-save-366/", + "/travel-quotes-122-3/", + "/suratthani-koh-samui-9-nights-save-154/", + "/barcelona-catalonia-7-nights-save-780/", + "/dont-give-up-the-daydream-no-23-2/", + "/barcelona-catalunya-8-nights-save-176/", + "/bellagio-lombardy-3-nights-save-795/", + "/where-am-i-26/", + "/harry-potter-themed-airbnbs/", + "/dont-give-up-the-daydream-no-23/", + "/medak-croatia-7-nights-save-335/", + "/north-side-grand-cayman-7-nights-save-792/", "/lucolena-florence-7-nights-save-423/", - "/lucolena-florence-7-nights-save-486/", - "/madrid-community-of-madrid-5-nights-save-1218/", "/madrid-community-of-madrid-8-nights-save-358/", - "/mailhoc-tarn-6-nights-save-991/", - "/makawao-hawaii-5-nights-save-54/", - "/malay-western-visayas-5-nights-save-120/", - "/manarola-liguria-9-nights-save-683/", + "/kamakura-kanagawa-japan-8-nights-save-158/", + "/firenze-toscana-8-nights-save-410/", + "/dublin-ireland-8-nights-save-161/", + "/madrid-community-of-madrid-5-nights-save-1218/", "/marrakesh-morocco-7-nights-save-367/", - "/medak-croatia-7-nights-save-335/", - "/megalochori-aegean-5-nights-save-183/", - "/miami-airbnb-with-pool-dive-into-your-dream-vacation/", + "/makawao-hawaii-5-nights-save-54/", "/monterotondo-marittimo-toscana-10-nights-save-78/", - "/montreuil-aux-lions-nord-pas-de-calais-picardie-6-nights-save-108/", - "/muggle-to-magician-stay-in-a-harry-potter-themed-airbnb/", - "/north-side-grand-cayman-7-nights-save-792/", "/occitanie-france-7-nights-save-68/", - "/oia-aegean-5-nights-save-1611/", - "/oklahoma-airbnbs-with-hot-tubs/", - "/orlando-fl-airbnb-your-ticket-to-paradise-in-the-heart-of-orlando/", - "/paris-ile-de-france-5-nights-save-366/", - "/paris-ile-de-france-7-nights-save-141/", - "/paris-ile-de-france-8-nights-save-282/", - "/passport-to-inspiration-a-deep-dive-into-the-best-travel-blogs-on-the-web/", - "/perledo-lecco-5-nights-save-96/", - "/pet-friendly-airbnb-a-furry-friendly-find-for-your-next-adventure/", - "/pet-friendly-airbnb-in-montana-with-paw-some-perks/", - "/pet-friendly-airbnb-in-north-carolina-where-tails-wag-and-hearts-warm/", - "/pet-friendly-airbnbs/", - "/phuket-thailand-8-nights-save-380/", - "/poggibonsi-toscana-7-nights-save-239/", - "/port-douglas-australia-9-nights-save-1287/", - "/praiano-campania-7-nights-save-86/", - "/price-check-this-orlando-airbnb-with-pool-and-save-money/", - "/property-spotlight-a-cool-tiny-house-with-big-savings/", - "/property-spotlight-amazing-glamping-rental/", - "/property-spotlight-unveiling-the-ultimate-castle-rental-experience/", - "/reykjavik-iceland-6-nights-save-248/", - "/roma-citta-metropolitana-di-roma-6-nights-save-120/", - "/roma-citta-metropolitana-di-roma-6-nights-save-374/", - "/roma-lazio-6-nights-save-431/", - "/romantic-airbnb-new-mexico-glamping-at-el-mistico-ranch/", - "/romantic-airbnbs/", - "/romantic-getaways/", - "/rome-lazio-3-nights-save-127/", - "/rome-lazio-4-nights-save-318/", - "/saint-martin-wallis-14-nights-save-1483/", - "/sainte-maxime-provence-alpes-cote-dazur-6-nights-save-450/", - "/sesimbra-setubal-7-nights-save-66/", - "/shute-harbour-queensland-14-nights-save-132/", - "/soller-balearic-islands-7-nights-save-637/", - "/south-kuta-bali-9-nights-save-1612/", - "/split-central-dalmatia-5-nights-save-116/", - "/suratthani-koh-samui-9-nights-save-154/", - "/surfers-paradise-australia-7-nights-save-108/", - "/sussex-inlet-australia-5-nights-save-169/", - "/swampscott-massachusetts-4-nights-save-211/", - "/tambon-kammala-phuket-province-8-nights-save-714/", - "/tampa-airbnb-with-hot-tub-treat-yourself-to-a-romantic-break/", - "/taormina-sicilia-4-nights-save-268/", - "/tawonga-south-australia-5-nights-save-185/", - "/tenerife-spain-7-nights-save-675/", - "/the-best-airbnb-wedding-venues-in-the-us/", - "/the-best-travel-accessories-for-2024-a-must-have-guide-for-globetrotters/", - "/the-most-expensive-airbnbs-in-the-caribbean/", - "/the-redditors-guide-to-airbnb-hosting-best-communities-for-advice-and-support/", - "/the-spellbinding-stay-experience-a-harry-potter-airbnb-adventure/", - "/tie-the-knot-at-this-fancy-airbnb-wedding-venue-in-florida/", - "/top-eerie-escapes-to-stay-for-halloween/", - "/torcigliano-tuscany-7-nights-save-1294/", - "/travel-quotes-122-3/", - "/travel-quotes-123/", - "/truckee-california-10-nights-save-7117/", - "/twickenham-england-6-nights-save-462/", - "/unique-airbnb-stays/", - "/unique-airbnb-wedding-venue-to-tie-the-knot/", - "/valenca-do-douro-viseu-3-nights-save-63/", - "/valtournenche-valle-daosta-8-nights-save-155/", - "/venice-veneto-italy-9-nights-save-483/", - "/vogar-iceland-6-nights-save-172/", - "/waterfront-airbnb-tampa-fl-family-friendly-find-with-a-view/", - "/where-am-i-26/", - "/where-am-i-28/", - "/where-am-i-29/", - "/wilen-sarnen-obwalden-7-nights-save-324/", - "/zarbo-di-mare-sicilia-7-nights-save-259/" + "/firenze-toscana-7-nights-save-1037/", + "/hardwicke-bay-south-australia-8-nights-save-56/", + "/cahuita-limon-province-8-nights-save-117/", + "/catalunya-spain-8-nights-save-412/", + "/cul-de-sac-saint-martin-france-7-nights-save-617/", + "/conwy-wales-8-nights-save-607/", + "/booroobin-queensland-7-nights-save-385/", + "/bacalar-quintana-roo-6-nights-save-159-2/", + "/abcoude-amsterdam-8-nights-save-252/", + "/dont-give-up-the-daydream-no-20/", + "/glenbrook-nevada-6-nights-save-105/", + "/yufu-oita-japan-5-nights-save-144/", + "/kappeln-schleswig-holstein-8-nights-save-105/", + "/where-am-i-27/", + "/venice-veneto-7-nights-save-460/", + "/seminyak-bali-8-nights-save-194/", + "/skala-greece-7-nights-save-104/", + "/travel-quotes-122-2/", + "/roma-lazio-5-nights-save-60/", + "/dont-give-up-the-daydream-no-21/", + "/mount-martha-victoria-10-nights-save-50/", + "/lipari-sicilia-5-nights-save-256/", + "/where-am-i-25-2/", + "/ann-maria-florida-7-nights-save-254/", + "/ashford-washington-8-nights-save-84/", + "/bacalar-quintana-roo-6-nights-save-159/", + "/travel-quotes-122/", + "/cuzco-peru-5-nights-save-389/", + "/reggello-toscana-12-nights-save-178/", + "/paris-ile-de-france-4-nights-save-378/", + "/marina-del-cantone-campania-11-nights-save-117/", + "/where-am-i-25/", + "/state-of-santa-catarina-brazil-5-nights-save-1007/", + "/sant-josep-de-sa-talaia-pm-7-nights-save-474/", + "/roma-lazio-4-nights-save-712/", + "/travel-quotes-121/", + "/trapiche-malaga-provinz-5-nights-save-733/", + "/dont-give-up-the-daydream-no-19/", + "/selfoss-iceland-6-nights-save-1242/", + "/firenze-toscana-5-nights-save-140/", + "/kemenuh-bali-6-nights-save-543/", + "/choeng-mon-plai-laem-koh-samui-9-nights-save-55/", + "/chiba-japan-7-nights-save-1110/", + "/newquay-cornwall-england-3-nights-save-332/", + "/travel-quotes-119/", + "/fredericksburg-texas-7-nights-save-73/", + "/dont-give-up-the-daydream-no-18/", + "/quarteira-algarve-7-nights-save-787/", + "/mendocino-california-8-nights-save-137/", + "/edinburgh-scotland-6-nights-save-93/", + "/where-am-i-23-2/", + "/madeira-portugal-7-nights-save-1725/", + "/gregory-town-the-bahamas-4-nights-save-87/", + "/firenze-toscana-8-nights-save-1010/", + "/canggu-bali-10-nights-save-118/", + "/bellemare-plage-flacq-6-nights-save-65/", + "/dont-give-up-the-daydream-no-17/", + "/hernando-beach-florida-7-nights-save-270/", + "/garden-city-utah-9-nights-save-1151/", + "/florence-italy-5-nights-save-301/", + "/edinburgh-scotland-2-nights-save-140/", + "/celukan-bawang-bali-8-nights-save-898/", + "/travel-quotes-118-2/", + "/callao-salvaje-canarias-spain-7-nights-save-872/", + "/dont-give-up-the-daydream-no-16/", + "/firenze-toscana-7-nights-save-773/", + "/firenze-toscana-3-nights-save-67/", + "/etna-pennsylvania-10-nights-save-98/", + "/can-besso-jesus-balearic-islands-7-nights-save-1630/", + "/bifrost-west-iceland-6-nights-save-369/", + "/bellagio-lombardia-5-nights-save-335/", + "/travel-quotes-118/", + "/athens-attica-5-nights-save-144/", + "/dont-give-up-the-daydream-no-9/", + "/oia-santorini-7-nights-save-116/", + "/amsterdam-north-holland-5-nights-save-371/", + "/paris-departement-de-paris-6-nights-save-59/", + "/where-am-i-24-2/", + "/where-am-i-24/", + "/positano-campania-6-nights-save-146/", + "/roma-lazio-7-nights-save-178/", + "/travel-quotes-117-2/", + "/sorrento-campania-6-nights-save-69/", + "/jewett-new-york-10-nights-save-101/", + "/dont-give-up-the-daydream-no-14/", + "/lahaina-hawaii-5-nights-save-951/", + "/airbnb-beach-house-adventure-seaside-dreams-await/", + "/9-of-the-coolest-tiny-house-rentals-you-can-find-in-the-us-and-where-to-rent-them-for-less/", + "/lauterbrunnen-bern-9-nights-save-77/", + "/9-best-farmhouse-airbnbs-to-rent-in-the-us-for-less/", + "/where-am-i-23/", + "/airbnb-treehouse-in-montana-ski-hike-and-relax/", + "/logan-ohio-8-nights-save-684/", + "/merovigli-aegean-5-nights-save-1300/", + "/disney-themed-airbnb-in-reunion-for-the-princess-in-your-life/", + "/praiano-campania-7-nights-save-433/", + "/travel-quotes-117/", + "/miramar-beach-florida-6-nights-save-347/", + "/new-orleans-louisiana-8-nights-save-765/", + "/dont-give-up-the-daydream-no-13/", + "/panama-city-beach-florida-8-nights-save-350/", + "/park-city-utah-8-nights-save-856/", + "/9-best-chocolate-box-cottages-you-can-rent-in-the-uk/", + "/savannah-georgia-7-nights-save-4691/", + "/9-most-unique-rentals-you-can-find-in-the-us/", + "/sea-ranch-california-6-nights-save-112/", + "/9-of-the-best-glamping-rentals-you-can-find-in-the-us/", + "/sedona-arizona-7-nights-save-141/", + "/sevierville-tennessee-7-nights-save-138/", + "/the-sea-ranch-california-7-nights-save-171/", + "/thompsonville-michigan-5-nights-save-122/", + "/williams-arizona-8-nights-save-214/", + "/ventura-california-8-nights-save-2216/", + "/waikoloa-hawaii-7-nights-save-182/", + "/kihei-hawaii-6-nights-save-426/", + "/glendale-arizona-9-nights-save-1469/", + "/telluride-colorado-6-nights-save-285/", + "/stowe-vermont-5-nights-save-139/", + "/steinsland-hordaland-7-nights-save-682/", + "/st-leonards-on-sea-england-7-nights-save-5035/", + "/sedona-arizona-11-nights-save-102/", + "/pasadena-california-7-nights-save-85/", + "/the-ultimate-list-of-vacation-rental-events-that-you-should-definitely-go-to-2023/", + "/lahaina-hawaii-8-nights-save-137/", + "/happy-new-year/", + "/orange-beach-alabama-7-nights-save-194/", + "/lahaina-hawaii-6-nights-save-745/", + "/north-port-florida-7-nights-save-256/", + "/naples-new-york-10-nights-save-77/", + "/napili-hawaii-6-nights-save-219/", + "/lahaina-hawaii-5-nights-save-728/", + "/travel-quotes-116/", + "/kihei-hawaii-10-nights-save-2120/", + "/keystone-colorado-7-nights-save-1670/", + "/key-west-florida-5-nights-save-336/", + "/merry-christmas/", + "/where-am-i-22/", + "/kailua-kona-hawaii-9-nights-save-88/", + "/glen-ellen-california-10-nights-save-117/", + "/travel-quotes-115/", + "/galveston-texas-7-nights-save-196/", + "/gatlinburg-tennessee-5-nights-save-194/", + "/franklin-tennessee-7-nights-save-142/", + "/where-am-i-21/", + "/fort-walton-beach-florida-7-nights-save-352/", + "/fernandina-beach-florida-10-nights-save-1092/", + "/dillon-colorado-4-nights-save-372/", + "/travel-quotes-114/", + "/blowing-rock-north-carolina-6-nights-save-263/", + "/franklin-tennessee-14-nights-save-219/", + "/blowing-rock-north-carolina-8-nights-save-315/", + "/breckenridge-colorado-8-nights-save-1198/", + "/where-am-i-20/", + "/destin-florida-7-nights-save-1281/", + "/columbus-indiana-8-nights-save-286/", + "/clermont-florida-7-nights-save-278/", + "/travel-quotes-113/", + "/coconut-grove-miami-7-nights-save-593/", + "/broken-bow-oklahoma-5-nights-save-108/", + "/charlottesville-virginia-5-nights-save-281/", + "/avon-colorado-8-nights-save-2143/", + "/where-am-i-19/", + "/albrightsville-pennsylvania-8-nights-save-673/", + "/carolina-beach-north-carolina-8-nights-save-330/", + "/travel-quotes-112/", + "/bald-head-island-north-carolina-7-nights-save-523/", + "/st-andrews-scotland-8-nights-save-1433/", + "/schmitten-salzburg-6-nights-save-365/", + "/where-am-i-18/", + "/san-diego-california-7-nights-save-372/", + "/nisaki-greece-10-nights-save-73/", + "/mckinleyville-california-6-nights-save-259/", + "/travel-quotes-111/", + "/lauterbrunnen-bern-7-nights-save-202/", + "/lagos-portugal-6-nights-save-2849/", + "/koloa-hawaii-14-nights-save-73/", + "/daphne-alabama-6-nights-save-91/", + "/where-am-i-17/", + "/broken-bow-oklahoma-7-nights-save-113/", + "/avon-colorado-7-nights-save-193/", + "/amalfi-campania-7-nights-save-887/", + "/travel-quotes-110/", + "/monmouth-wales-5-nights-save-111/", + "/kendal-cumbria-14-nights-save-69/", + "/gwynedd-cymru-8-nights-save-238/", + "/where-am-i-16/", + "/dollar-clackmannanshire-7-nights-save-135/", + "/atrani-campania-6-nights-save-164/", + "/aberfoyle-scotland-5-nights-save-445/", + "/treminis-auvergne-rhone-alpes-8-nights-save-97/", + "/valais-switzerland-7-nights-save-185/", + "/west-yellowstone-montana-6-nights-save-138/", + "/where-am-i-15/", + "/mountain-village-colorado-7-nights-save-998/", + "/dubrovnik-neretva-county-croatia-7-nights-save-135/", + "/cooke-city-silver-gate-montana-8-nights-save-237/", + "/columbia-falls-montana-9-nights-save-2062/", + "/occitanie-france-7-nights-save-218/", + "/dont-give-up-the-daydream-no-12/", + "/panama-city-beach-florida-7-nights-save-100/", + "/south-tyrol-italy-7-nights-save-340/", + "/where-am-i-14/", + "/selat-bali-7-nights-save-236/", + "/kyle-texas-6-nights-save-3600/", + "/caylus-france-9-nights-save-135/", + "/nordland-norway-7-nights-save-329/", + "/dont-give-up-the-daydream-no-11/", + "/where-am-i-13/", + "/sausalito-california-7-nights-save-2087/", + "/valais-switzerland-7-nights-save-13637/", + "/paris-7-nights-save-2097/", + "/dont-give-up-the-daydream-no-10/", + "/palm-springs-7-nights-save-1051/", + "/where-am-i-12/", + "/hot-springs-arkansas-10-nights-save-67/", + "/miami-5-nights-save-416/", + "/hilton-head-island-south-carolina-6-nights-save-985/", + "/clermont-florida-3-nights-save-654/", + "/bethany-beach-delaware-7-nights-save-940/", + "/noyers-sur-cher-6-nights-save-132/", + "/nashville-7-nights-save-292/", + "/los-angeles-6-nights-save-423/", + "/cape-town-6-nights-save-522/", + "/amsterdam-9-nights-save-514/", + "/hawaii-7-nights-save-500/", + "/where-am-i-10/", + "/tennessee-5-nights-save-377/", + "/miami-10-nights-save-801/", + "/georgia-5-nights-save-602/", + "/costa-rica-7-nights-save-984/", + "/california-7-nights-save-143/", + "/texas-6-nights-save-101/", + "/queenstown-4-nights-save-800/", + "/where-am-i-9/", + "/asturias-9-nights-save-111/", + "/faro-7-nights-save-3652/", + "/fernhill-5-nights-save-72/", + "/new-south-wales-5-nights-save-1997/", + "/breckenridge-6-nights-save-105/", + "/albion-5-nights-save-257/", + "/nayarit-7-nights-save-86/", + "/chicago-6-nights-save-1207/", + "/lahaina-5-nights-save-295/", + "/saint-augustine-10-nights-save-173/", + "/miami-6-nights-save-399/", + "/baie-saint-paul-10-nights-save-103/", + "/valle-real-6-nights-save-745/", + "/sicilia-7-nights-save-524/", + "/alicante-6-nights-save-173/", + "/barvaux-sur-ourthe-6-nights-save-86/", + "/clackmannanshire-5-nights-save-222/", + "/abcoude-5-nights-save-520/", + "/sicilia-6-nights-save-127/", + "/ljubljana-10-nights-save-159/", + "/ionian-islands-6-nights-save-225/", + "/madrid-7-nights-save-203/", + "/baden-wurttemberg-10-nights-save-180/", + "/sopley-8-nights-save-209/", + "/granada-10-nights-save-115/", + "/province-of-brindisi-7-nights-save-2435/", + "/sardegna-6-nights-save-110/", + "/paleokastritsa-7-nights-save-284/", + "/noizay-9-nights-save-101/", + "/arezzo-5-nights-save-145/", + "/corfu-7-nights-save-596/", + "/darzkowo-6-nights-save-113/", + "/fayston-4-nights-save-147/", + "/marina-6-nights-save-3138/", + "/kostanje-11-nights-save-1184/", + "/rome-7-nights-save-233/", + "/east-hampton-6-nights-save-135/", + "/yallingup-7-nights-save-181/", + "/elevated-plains-8-nights-save-105/", + "/roma-8-nights-save-117/", + "/dauphin-island-9-nights-save-88/", + "/bayard-10-nights-save-299/", + "/tennessee-6-nights-save-90/", + "/the-ultimate-list-of-vacation-rental-events-that-you-should-definitely-go-to/", + "/new-zealand-6-nights-save-189/", + "/croatia-7-nights-save-174/", + "/colorado-10-nights-save-518/", + "/california-7-nights-save-3327/", + "/looking-for-an-airbnb-beach-house-these-are-jaw-dropping/", + "/dont-give-up-the-daydream-no-8/", + "/mexico-7-nights-save-293/", + "/tennessee-7-nights-save-439/", + "/where-am-i-8/", + "/california-5-nights-save-59/", + "/tennessee-5-nights-save-87/", + "/valencian-community-5-nights-save-118/", + "/michigan-7-nights-save-4131/", + "/utah-10-nights-save-117/", + "/9-magical-disney-themed-rentals-you-can-find-in-the-us-and-where-to-rent-them-for-less/", + "/dont-give-up-the-daydream-no-7/", + "/tennessee-7-nights-save-107/", + "/mexico-6-nights-save-222/", + "/where-am-i-7/", + "/little-cayman-7-nights-save-343/", + "/malibu-7-nights-save-868/", + "/tennessee-10-nights-save-73/", + "/michigan-7-nights-save-230/", + "/california-7-nights-save-1915-2/", + "/dont-give-up-the-daydream-no-6/", + "/florida-7-nights-save-276/", + "/michigan-7-nights-save-267/", + "/tennessee-5-nights-save-170/", + "/where-am-i-6/", + "/joshua-tree-5-nights-save-130/", + "/dont-give-up-the-daydream-no-5/", + "/where-am-i-5/", + "/where-am-i-4/", + "/airbnb-castles-9-budget-friendly-fairytales-in-the-u-s/" + ], + "alan": [ + "/hot-tub-photography-for-airbnb-hosts/", + "/build-a-backyard-pickleball-court-for-more-bookings/", + "/the-10-best-things-to-do-in-miami-a-video-guide/", + "/airbnb-bookings-hack-get-20-more-bookings-starting-today/", + "/today-is-book-direct-day-but-does-it-really-work/", + "/airbnb-alternatives-hipcamp-guide-discovering-the-best-hidden-gems/", + "/airbnb-alternatives-airbnb-v-booking-com-decoding-the-best-deals/", + "/airbnb-alternatives-airbnb-v-vrbo-a-guide-for-savvy-travelers/", + "/pet-friendly-airbnb-guide/", + "/airbnbs-latest-update-sparks-outrage-could-this-be-the-end-for-many-hosts/", + "/airbnb-hosts-is-it-time-to-review-your-views-on-reviews/", + "/vrbo-and-airbnb-youll-never-guess-what-vrbo-are-doing/", + "/were-all-in-the-airbnb-business-now-part-2/", + "/why-youll-be-750-times-better-off-running-an-airbnb-than-a-vacation-rental-or-holiday-let/", + "/vacation-rental-website-builders-what-are-your-best-options/", + "/airbnb-alternatives-2024/", + "/airbnb-gift-cards-all-you-need-to-know-about-these-perfect-gifts/", + "/smart-home-devices-for-your-airbnb-a-complete-guide/", + "/smart-home-monitoring-for-your-airbnb-a-complete-guide-pt-2/", + "/best-airbnb-smart-home-devices-part-3-moneymaking-wifi/", + "/want-to-really-get-away-heres-some-expert-advice-for-digital-nomad-accommodations/", + "/the-pros-and-cons-of-booking-direct-vacation-rental-secrets/", + "/vacation-rentals-cancellation-policies-compared-all-you-need-to-know/", + "/vacation-rental-damage-deposits-all-you-need-to-know-including-tips-from-hosts/", + "/the-ultimate-guide-to-selecting-the-perfect-travel-insurance-for-your-2023-adventure/", + "/how-to-furnish-your-airbnb-like-a-pro-and-a-few-shopping-secrets/", + "/20-free-tools-that-make-your-hospitality-website-rock/", + "/holiday-let-emails-messages-to-send-to-guests/", + "/free-short-term-rental-agreement-templates-word-pdf/", + "/free-airbnb-welcome-book-template/", + "/airbnb-rental-arbitrage-and-how-to-succeed-at-it/", + "/the-short-term-rental-hosts-guide-on-how-to-survive-the-recession/", + "/getting-more-direct-bookings-the-value-of-repeat-guests/", + "/airbnb-ceo-vows-to-tackle-runaway-cleaning-fees/", + "/travel-quotes-109/", + "/travel-quotes-108/", + "/the-coming-collapse-of-airbnb-short-term-rentals/", + "/travel-quotes-107/", + "/travel-quotes-106/", + "/amazing-disney-themed-vacation-rental-in-kissimmee-and-how-to-book-it-for-less/", + "/travel-hack-airbnb-5-times-the-price-i-save-4100-in-1-minute/", + "/check-out-this-wonderful-treehouse-and-book-it-for-less/", + "/travel-quotes-105/", + "/does-social-media-drive-direct-bookings-for-your-vacation-rental/", + "/5-steps-to-clean-up-and-organize-your-vacation-rental-website/", + "/labor-day-weekend-2022-vacation-rental-market-data-forecast/", + "/lowest-price-vacation-rentals-magic-search-without-witchcraft/", + "/7-short-term-rental-myths-debunked/", + "/vacation-rentals-book-direct-with-the-owner-and-save-heres-how/", + "/travel-quotes-104/", + "/vacation-rentals-best-price-and-100-peace-of-mind-every-time/", + "/dont-get-scammed-on-airbnb-find-out-the-ugly-truth-and-save-big-money/", + "/dont-give-up-the-daydream-no-4/", + "/airbnbs-10-fold-increase-in-host-cancellation-fees-could-benefit-guests/", + "/fair-use/", + "/the-meaning-behind-hichee/", + "/where-am-i-3/", + "/where-am-i-2/", + "/where-am-i-1/", + "/dont-give-up-the-daydream-no-3/", + "/travel-quotes-103/", + "/vacation-rental-price-comparison-watch-as-i-save-5836-on-this-airbnb-in-under-1-minute/", + "/pack-up-and-go-with-these-15-travel-hacks-and-more-diy-ideas-by-crafty-panda/", + "/travel-quotes-102/", + "/dont-give-up-the-daydream-no-2/", + "/dont-give-up-the-daydream-no-1/", + "/travel-quotes-101/", + "/amazon-travel-essentials-you-need-in-2022-my-top-10-travel-products/", + "/top-10-travel-tips-tricks-from-a-flight-attendant/", + "/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022/", + "/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo/", + "/airbnb-or-vrbo-where-should-i-list-my-property/", + "/youll-lose-thousands-hosting-on-airbnb-if-you-do-this/", + "/gatlinburg-4-nights-save-192/", + "/new-york-city-3-nights-save-474/", + "/vacation-rental-deals-st-barts-4-guests-july-3rd-july-16th/", + "/dont-give-up-the-daydream/" + ], + "our-discount-desk": [ + "/north-carolina-4-nights-save-41/", + "/indiana-5-nights-save-661/", + "/catalonia-6-nights-save-121/", + "/saint-lucia-7-nights-save-484/", + "/california-7-nights-save-1915/", + "/florida-5-nights-save-1861/", + "/quebec-3-nights-save-217/", + "/arizona-4-nights-save-108/", + "/arizona-7-nights-save-295/", + "/costa-rica-7-nights-save-110/", + "/cancun-7-nights-save-3927/", + "/35-secrets-to-save-money-on-your-next-vacation/" + ], + "our-travel-reporter": [ + "/top-9-website-builders-property-managers-use-for-their-vacation-rental/", + "/top-7-best-vacation-rental-channel-managers-in-2022/", + "/airbnb-cabins-get-close-to-nature-in-these-hidden-gems/", + "/see-how-to-save-over-100-a-night-on-this-wonderful-treehouse-in-georgia/", + "/property-spotlight-stunning-beach-house-and-how-to-book-it-for-less/", + "/property-spotlight-a-delightful-a-frame-cabin-best-price-revealed/", + "/airbnb-treehouses/" ] } diff --git a/src/data/route-aliases.json b/src/data/route-aliases.json new file mode 100644 index 0000000..729b628 --- /dev/null +++ b/src/data/route-aliases.json @@ -0,0 +1,6 @@ +{ + "/where-am-i-24-2/": "/where-am-i-24/", + "/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022\uFFFC/": "/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022/", + "/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo\uFFFC/": "/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo/", + "/youll-lose-thousands-hosting-on-airbnb-if-you-do-this\uFFFC/": "/youll-lose-thousands-hosting-on-airbnb-if-you-do-this/" +} diff --git a/src/lib/authorArchives.ts b/src/lib/authorArchives.ts new file mode 100644 index 0000000..28785ca --- /dev/null +++ b/src/lib/authorArchives.ts @@ -0,0 +1,116 @@ +import { getCollection, type CollectionEntry } from 'astro:content'; +import authorPostPaths from '../data/author-post-paths.json'; +import { canonicalizeAliasPath, normalizeRoutePath } from './routePaths'; + +export const AUTHOR_PAGE_SIZE = 12; + +type AuthorProfile = { + name: string; + bio: string; +}; + +export type AuthorArchivePost = Pick, 'id' | 'data'>; + +export type AuthorArchiveEntry = { + slug: string; + profile: AuthorProfile; + posts: AuthorArchivePost[]; + totalPages: number; +}; + +export const authorProfiles: Record = { + alan: { + name: 'Alan Egan', + bio: 'Alan has been working in the vacation rental sector since 2004, when he first created a listing site for his property management company. He has been helping short-term rental owners and managers to stand out in an over-saturated marketplace for over 12 years and has written thousands of articles in that time. He has written books on vacation rental photography and was the first in the industry to create online marketing courses for hosts. He has given keynote presentations across various subjects at The Vacation Rental World Summit, VRMA, VRMintel, Host, and The Book Direct Summit.' + }, + tiffany: { + name: 'Tiffany Martin', + bio: "Tiffany Martin, a 33-year-old travel content contributor based in Manila, Philippines, brings the world's beauty to your screen through her writing, narrating the best places in the globe with a charm that resonates. Aside from being a travel writer and an all-around digital nomad, she's also a wifey, a mom to two girls, and a licensed educator. During her downtime, she loves traveling, cooking, and playing with her energetic Dachshund and cuddly Golden Malinois." + }, + 'our-discount-desk': { + name: 'Our Discount Desk', + bio: 'Posts and travel deals curated by the HiChee team.' + }, + 'our-travel-reporter': { + name: 'Our Travel Reporter', + bio: 'Travel reporting and destination coverage from the HiChee team.' + } +}; + +export async function getAuthorArchiveEntries(): Promise { + const posts = (await getCollection('posts')).filter( + (entry) => !entry.data.draft && entry.data.status === 'publish' + ); + + const postsByPath = new Map( + posts.map((post) => [normalizeRoutePath(post.data.path), post] as const) + ); + + return Object.entries(authorPostPaths).map(([slug, paths]) => { + const authorPosts = (paths as string[]) + .map((entryPath) => resolveArchivePost(entryPath, postsByPath)) + .filter((post): post is AuthorArchivePost => Boolean(post)) + .sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); + + return { + slug, + profile: getAuthorProfile(slug), + posts: authorPosts, + totalPages: Math.max(1, Math.ceil(authorPosts.length / AUTHOR_PAGE_SIZE)) + }; + }); +} + +export function getAuthorProfile(slug: string): AuthorProfile { + const profile = authorProfiles[slug]; + if (profile) return profile; + + const name = titleCaseSlug(slug); + return { + name, + bio: `Posts written by ${name}.` + }; +} + +export function buildAuthorPagePath(slug: string, page: number): string { + if (page <= 1) return `/author/${slug}/`; + return `/author/${slug}/page/${page}/`; +} + +export function paginateAuthorPosts(posts: AuthorArchivePost[], page: number) { + const currentPage = Math.max(1, page); + const start = (currentPage - 1) * AUTHOR_PAGE_SIZE; + return posts.slice(start, start + AUTHOR_PAGE_SIZE); +} + +function resolveArchivePost( + route: string, + postsByPath: Map> +): AuthorArchivePost | null { + const normalizedRoute = normalizeRoutePath(route, { canonicalize: false }); + const direct = postsByPath.get(normalizedRoute); + if (direct) return direct; + + const canonicalRoute = canonicalizeAliasPath(normalizedRoute); + if (canonicalRoute === normalizedRoute) return null; + + const canonicalPost = postsByPath.get(canonicalRoute); + if (!canonicalPost) return null; + + return { + ...canonicalPost, + id: `${canonicalPost.id}::${normalizedRoute}`, + data: { + ...canonicalPost.data, + path: normalizedRoute, + }, + }; +} + +function titleCaseSlug(slug: string): string { + return slug + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} diff --git a/src/lib/routePaths.ts b/src/lib/routePaths.ts new file mode 100644 index 0000000..268953c --- /dev/null +++ b/src/lib/routePaths.ts @@ -0,0 +1,32 @@ +import routeAliasesRaw from '../data/route-aliases.json'; + +const routeAliases = routeAliasesRaw as Record; +const routeAliasMap = new Map(Object.entries(routeAliases)); + +type NormalizeRoutePathOptions = { + canonicalize?: boolean; +}; + +export const routeAliasEntries = Object.entries(routeAliases) as [string, string][]; + +export function canonicalizeAliasPath(route: string): string { + return routeAliasMap.get(route) ?? route; +} + +export function normalizeRoutePath( + inputPath: string, + options: NormalizeRoutePathOptions = {} +): string { + const { canonicalize = true } = options; + const raw = String(inputPath || '') + .replace(/https?:\/\/blog\.hichee\.com/i, '') + .replace(/%ef%bf%bc/gi, '') + .replace(/\uFFFC/g, '') + .trim(); + + if (!raw) return '/'; + + const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`; + const normalized = withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/`; + return canonicalize ? canonicalizeAliasPath(normalized) : normalized; +} diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 41a3f28..611d71a 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -6,6 +6,7 @@ import legacySimilarBlocksByRoute from '../data/legacy-similar-blocks.json'; import legacyRelatedPostsByRouteRaw from '../data/legacy-related-posts.json'; import authorPostPaths from '../data/author-post-paths.json'; import { normalizeWpUploadUrl, rewriteWpUploadsInHtml } from '../lib/media'; +import { normalizeRoutePath, routeAliasEntries } from '../lib/routePaths'; export async function getStaticPaths() { const allEntries = [ @@ -14,22 +15,6 @@ export async function getStaticPaths() { ]; const byPath = new Map(allEntries.map((entry) => [entry.data.path, entry] as const)); - const aliasPathToCanonicalPath = new Map([ - ['/where-am-i-24-2/', '/where-am-i-24/'], - [ - '/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022\uFFFC/', - '/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022/' - ], - [ - '/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo\uFFFC/', - '/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo/' - ], - [ - '/youll-lose-thousands-hosting-on-airbnb-if-you-do-this\uFFFC/', - '/youll-lose-thousands-hosting-on-airbnb-if-you-do-this/' - ] - ]); - const contentPaths = allEntries .filter((entry) => entry.data.path !== '/') .map((entry) => ({ @@ -37,7 +22,7 @@ export async function getStaticPaths() { props: { entry } })); - const aliasPaths = [...aliasPathToCanonicalPath.entries()] + const aliasPaths = routeAliasEntries .map(([aliasPath, canonicalPath]) => { const entry = byPath.get(canonicalPath); if (!entry) return null; @@ -561,13 +546,6 @@ function inferAuthorSlugFromPath( return ''; } -function normalizeRoutePath(inputPath: string): string { - const raw = String(inputPath || '').replace(/%ef%bf%bc/gi, '').replace(/\uFFFC/g, ''); - if (!raw) return '/'; - const withStart = raw.startsWith('/') ? raw : `/${raw}`; - return withStart.endsWith('/') ? withStart : `${withStart}/`; -} - function pickRelatedPosts< T extends { id: string; data: { path: string; title: string; date: Date; categories: string[]; tags: string[] } } >({ @@ -590,21 +568,6 @@ function pickRelatedPosts< legacyRelatedByRoute: Record; }): { post: T; href: string }[] { const byPath = new Map(allPosts.map((post) => [normalizeRoutePath(post.data.path), post] as const)); - const aliasPathToCanonicalPath = new Map([ - ['/where-am-i-24-2/', '/where-am-i-24/'], - [ - '/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022\uFFFC/', - '/best-cheap-flights-websites-nobody-is-talking-about-how-to-find-cheap-flights-2022/' - ], - [ - '/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo\uFFFC/', - '/how-to-get-longer-bookings-and-monthly-bookings-on-airbnb-and-vrbo/' - ], - [ - '/youll-lose-thousands-hosting-on-airbnb-if-you-do-this\uFFFC/', - '/youll-lose-thousands-hosting-on-airbnb-if-you-do-this/' - ] - ]); const route = normalizeRoutePath(currentPath); const legacyPaths = legacyRelatedByRoute[route] ?? []; const targetCount = legacyPaths.length > 0 ? Math.min(6, legacyPaths.length) : 3; @@ -614,8 +577,7 @@ function pickRelatedPosts< for (const relatedPath of legacyPaths) { const normalizedRelatedPath = normalizeRoutePath(relatedPath); - const canonicalRelatedPath = aliasPathToCanonicalPath.get(normalizedRelatedPath) ?? normalizedRelatedPath; - const mapped = byPath.get(canonicalRelatedPath); + const mapped = byPath.get(normalizedRelatedPath); if (!mapped) continue; if (mapped.id === currentPostId || seen.has(mapped.id)) continue; out.push({ post: mapped, href: normalizedRelatedPath }); diff --git a/src/pages/author/[slug].astro b/src/pages/author/[slug].astro index d3d88b0..8789502 100644 --- a/src/pages/author/[slug].astro +++ b/src/pages/author/[slug].astro @@ -1,90 +1,74 @@ --- -import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import PostCard from '../../components/PostCard.astro'; -import authorPostPaths from '../../data/author-post-paths.json'; - -type AuthorProfile = { - name: string; - bio: string; -}; - -function titleCaseSlug(slug: string): string { - return slug - .split(/[-_]/g) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); -} - -const authorProfiles: Record = { - alan: { - name: 'Alan Egan', - bio: 'Alan has been working in the vacation rental sector since 2004, when he first created a listing site for his property management company. He has been helping short-term rental owners and managers to stand out in an over-saturated marketplace for over 12 years and has written thousands of articles in that time. He has written books on vacation rental photography and was the first in the industry to create online marketing courses for hosts. He has given keynote presentations across various subjects at The Vacation Rental World Summit, VRMA, VRMintel, Host, and The Book Direct Summit.' - }, - tiffany: { - name: 'Tiffany Martin', - bio: "Tiffany Martin, a 33-year-old travel content contributor based in Manila, Philippines, brings the world's beauty to your screen through her writing, narrating the best places in the globe with a charm that resonates. Aside from being a travel writer and an all-around digital nomad, she's also a wifey, a mom to two girls, and a licensed educator. During her downtime, she loves traveling, cooking, and playing with her energetic Dachshund and cuddly Golden Malinois." - }, - 'our-discount-desk': { - name: 'Our Discount Desk', - bio: 'Posts and travel deals curated by the HiChee team.' - }, - 'our-travel-reporter': { - name: 'Our Travel Reporter', - bio: 'Travel reporting and destination coverage from the HiChee team.' - } -}; +import PaginationNav from '../../components/PaginationNav.astro'; +import { + AUTHOR_PAGE_SIZE, + buildAuthorPagePath, + getAuthorArchiveEntries, + type AuthorArchivePost, +} from '../../lib/authorArchives'; export async function getStaticPaths() { - const posts = (await getCollection('posts')).filter( - (entry) => !entry.data.draft && entry.data.status === 'publish' - ); - const normalizePath = (inputPath: string) => { - const raw = String(inputPath || '').replace(/%ef%bf%bc/gi, '').replace(/\uFFFC/g, ''); - if (!raw) return '/'; - const withLeadingSlash = raw.startsWith('/') ? raw : `/${raw}`; - return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/`; - }; - const postsByPath = new Map(posts.map((post) => [normalizePath(post.data.path), post] as const)); - - return Object.entries(authorPostPaths).map(([slug, paths]) => { - const authorPosts = (paths as string[]) - .map((path) => postsByPath.get(normalizePath(path))) - .filter((post): post is (typeof posts)[number] => Boolean(post)) - .sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); - + const archives = await getAuthorArchiveEntries(); + return archives.map(({ slug, profile, posts, totalPages }) => { return { params: { slug }, - props: { posts: authorPosts } + props: { + slug, + profile, + posts: posts.slice(0, AUTHOR_PAGE_SIZE), + totalPosts: posts.length, + currentPage: 1, + totalPages + } }; }); } -const slug = Astro.params.slug as string; -const posts = [...((Astro.props.posts ?? []) as any[])].sort( - (a, b) => b.data.date.getTime() - a.data.date.getTime() -); - -const profile = authorProfiles[slug] ?? { - name: titleCaseSlug(slug), - bio: `Posts written by ${titleCaseSlug(slug)}.` +const { slug, profile, posts, totalPosts, currentPage, totalPages } = Astro.props as { + slug: string; + profile: { name: string; bio: string }; + posts: AuthorArchivePost[]; + totalPosts: number; + currentPage: number; + totalPages: number; }; + +const title = + currentPage > 1 + ? `${profile.name}, Author at The HiChee Blog and Travel Guide - Page ${currentPage}` + : `${profile.name}, Author at The HiChee Blog and Travel Guide`; + +const description = + currentPage > 1 + ? `Author archive for ${profile.name}, page ${currentPage}. ${profile.bio}` + : `Author archive for ${profile.name}. ${profile.bio}`; ---

Author

-

Archives for {profile.name}

+

Author: {profile.name}

{profile.bio}

-

{posts.length} posts

+

+ {totalPosts} posts + {totalPages > 1 && ` • Page ${currentPage} of ${totalPages}`} +

{posts.map((post) => )}
+ +
diff --git a/src/pages/author/[slug]/page/[page].astro b/src/pages/author/[slug]/page/[page].astro new file mode 100644 index 0000000..c3d2efa --- /dev/null +++ b/src/pages/author/[slug]/page/[page].astro @@ -0,0 +1,69 @@ +--- +import BaseLayout from '../../../../layouts/BaseLayout.astro'; +import PaginationNav from '../../../../components/PaginationNav.astro'; +import PostCard from '../../../../components/PostCard.astro'; +import { + buildAuthorPagePath, + getAuthorArchiveEntries, + paginateAuthorPosts, + type AuthorArchivePost, +} from '../../../../lib/authorArchives'; + +export async function getStaticPaths() { + const archives = await getAuthorArchiveEntries(); + const paths = []; + + for (const { slug, profile, posts, totalPages } of archives) { + for (let page = 2; page <= totalPages; page += 1) { + paths.push({ + params: { slug, page: String(page) }, + props: { + slug, + profile, + posts: paginateAuthorPosts(posts, page), + totalPosts: posts.length, + currentPage: page, + totalPages + } + }); + } + } + + return paths; +} + +const { slug, profile, posts, totalPosts, currentPage, totalPages } = Astro.props as { + slug: string; + profile: { name: string; bio: string }; + posts: AuthorArchivePost[]; + totalPosts: number; + currentPage: number; + totalPages: number; +}; +--- + + +
+

Author

+

Author: {profile.name}

+

{profile.bio}

+

+ {totalPosts} posts • Page {currentPage} of {totalPages} +

+
+ +
+ {posts.map((post) => )} +
+ + +
diff --git a/src/styles/global.css b/src/styles/global.css index 5f06034..129f577 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -201,6 +201,46 @@ a:hover { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.pagination-nav { + margin: 1.5rem 0 0; +} + +.pagination-nav__list { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.page-numbers { + min-width: 2.4rem; + padding: 0.6rem 0.95rem; + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + color: var(--text); + font-weight: 600; + text-align: center; +} + +a.page-numbers:hover { + border-color: var(--link); +} + +.page-numbers.current { + border-color: var(--link); + background: var(--link); + color: #fff; +} + +.page-numbers--ellipsis { + border: 0; + background: transparent; + color: var(--muted); + min-width: auto; + padding-inline: 0.2rem; +} + .post-card { border: 1px solid var(--line); border-radius: 8px;