import { randomUUID } from 'node:crypto' export const DEFAULT_REDIRECT_RULES = [ { id: 'spotify', path: '/spotify', target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl', statusCode: 301, }, { id: 'apple', path: '/apple', target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate', statusCode: 301, }, { id: 'amazon', path: '/amazon', target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate', statusCode: 301, }, ] export const DEFAULT_SEO = { title: 'Verse by Verse with Nate', description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.', ogTitle: 'Verse by Verse with Nate', ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.', ogImage: '/images/podcast-art.jpeg', canonicalUrl: 'https://versebyversewithnate.us/', robotsPolicy: 'index,follow', sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms', '/about', '/contact', '/episodes', '/resources', '/study', '/study/titus', '/study/colossians'], } export const DEFAULT_LEGAL = { privacyTitle: 'Privacy Policy', privacyBody: [ 'We respect your privacy and collect limited data to operate and improve this site.', 'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.', 'Contact form details are used only to respond to your message and ministry communication requests.', ], termsTitle: 'Terms', termsBody: [ 'Content on this site is for informational and ministry purposes.', 'External links are provided for convenience and are subject to third-party policies.', 'By using this site, you agree to lawful use and respectful communication.', ], } export const DEFAULT_PODCAST_FEATURED_LINKS = [] export const DEFAULT_PUBLISH_STATE = { draftUpdatedAt: null, publishedAt: null, } export function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export function splitName(fullName) { const parts = String(fullName).trim().split(/\s+/).filter(Boolean) return { firstName: parts[0] ?? '', lastName: parts.slice(1).join(' '), } } export function sanitizeUrl(value) { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' if (trimmed.startsWith('/')) return trimmed if (/^https?:\/\//i.test(trimmed)) return trimmed return '' } export function normalizeRedirectPath(value) { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` const normalized = withSlash.replace(/\/+/g, '/') if (normalized === '/') return '' if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' return normalized } export function normalizeSitemapPath(value) { if (typeof value !== 'string') return '' const trimmed = value.trim() if (!trimmed) return '' if (trimmed === '/') return '/' return normalizeRedirectPath(trimmed) } export function sanitizeRedirectRules(value) { const source = Array.isArray(value) ? value : [] const seen = new Set() const out = [] for (const item of source) { const pathValue = normalizeRedirectPath(item?.path) const target = sanitizeUrl(item?.target) const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 if (!pathValue || !target) continue if (seen.has(pathValue)) continue seen.add(pathValue) out.push({ id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), path: pathValue, target, statusCode, }) } return out.length > 0 ? out : DEFAULT_REDIRECT_RULES } export function sanitizeFeaturedLinks(value) { const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => { const discussionQuestions = Array.isArray(item.discussionQuestions) ? item.discussionQuestions .filter(question => typeof question === 'string') .map(question => question.trim()) .filter(Boolean) .slice(0, 30) : [] return { id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '', episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '', summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '', url: sanitizeUrl(item.url), embedUrl: sanitizeUrl(item.embedUrl), showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '', discussionQuestions, } }) .filter( item => item.title || item.summary || item.url || item.embedUrl || item.showNotes || item.discussionQuestions.length > 0, ) } function sanitizeCustomLinks(value) { const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => { const placement = item?.placement === 'platforms' || item?.placement === 'footer' || item?.placement === 'resources' || item?.placement === 'otherSites' || item?.placement === 'externalSites' ? item.placement : 'footer' return { id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '', url: sanitizeUrl(item.url), imageUrl: sanitizeUrl(item.imageUrl), description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '', amazonUrl: sanitizeUrl(item.amazonUrl), amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '', tags: Array.isArray(item.tags) ? item.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean).slice(0, 20) : [], placement, } }) .filter(item => item.label && item.url) } function sanitizeCustomBlocks(value) { const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '', body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '', page: item?.page === 'homepage' || item?.page === 'start-here' || item?.page === 'episodes' || item?.page === 'downloads' || item?.page === 'about' || item?.page === 'contact' || item?.page === 'questions' ? item.page : 'downloads', })) .filter(item => item.heading || item.body) } function sanitizeArchivedSeriesResourceLinks(value) { const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '', description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '', url: sanitizeUrl(item.url), amazonUrl: sanitizeUrl(item.amazonUrl), amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '', })) .filter(item => item.label && item.url) } function sanitizeArchivedSeriesNotes(value) { const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '', body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '', })) .filter(item => item.heading || item.body) } function sanitizeColossiansStudySections(value) { function normalizeReleasedAt(value) { if (typeof value !== 'string' || !value.trim()) return '' const parsed = Date.parse(value.trim()) if (!Number.isFinite(parsed)) return '' return new Date(parsed).toISOString().replace('.000Z', 'Z') } const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => { const releasedAt = normalizeReleasedAt(item.releasedAt) return { id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1, reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '', title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '', audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl), passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '', summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '', commentary: typeof item.commentary === 'string' ? item.commentary.trim().slice(0, 12000) : '', greekNotes: Array.isArray(item.greekNotes) ? item.greekNotes.filter(note => typeof note === 'string').map(note => note.trim()).filter(Boolean).slice(0, 20) : [], studyQuestions: Array.isArray(item.studyQuestions) ? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20) : [], checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '', checkpointQuestions: Array.isArray(item.checkpointQuestions) ? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20) : [], ...(releasedAt ? { releasedAt } : {}), } }) .filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0) } function sanitizeStudies(value) { const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), slug: typeof item.slug === 'string' ? item.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') : '', title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '', description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '', homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '', showOnHomepage: item.showOnHomepage === true, showNewTag: item.showNewTag === true, newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '', status: item.status === 'planned' ? 'planned' : 'active', difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'), estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8, completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '', numberOfChapters: Number.isInteger(item.numberOfChapters) && item.numberOfChapters >= 1 && item.numberOfChapters <= 999 ? item.numberOfChapters : 1, sections: sanitizeColossiansStudySections(item.sections), })) .filter(item => item.slug && item.title) } function sanitizeArchivedSeries(value) { const source = Array.isArray(value) ? value : [] return source .filter(item => item && typeof item === 'object') .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '', title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '', description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '', imageUrl: sanitizeUrl(item.imageUrl), listenUrl: sanitizeUrl(item.listenUrl), studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '', studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '', studyGuideUrl: sanitizeUrl(item.studyGuideUrl), resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks), notes: sanitizeArchivedSeriesNotes(item.notes), episodeRange: Number.isInteger(item?.episodeRange?.from) && Number.isInteger(item?.episodeRange?.to) && item.episodeRange.from > 0 && item.episodeRange.to >= item.episodeRange.from ? { from: item.episodeRange.from, to: item.episodeRange.to } : undefined, })) .filter( item => item.title || item.description || item.resourceLinks.length > 0 || item.notes.length > 0, ) } export function sanitizeSiteContent(siteContent) { if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {} const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {} const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {} return { ...siteContent, customLinks: sanitizeCustomLinks(siteContent.customLinks), customBlocks: sanitizeCustomBlocks(siteContent.customBlocks), archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries), studies: sanitizeStudies(siteContent.studies), colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections), redirects: sanitizeRedirectRules(siteContent.redirects), podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS), episodesSeoIntro: typeof siteContent.episodesSeoIntro === 'string' && siteContent.episodesSeoIntro.trim() ? siteContent.episodesSeoIntro.trim().slice(0, 600) : '', welcomeEmailSpotifyBtnLabel: typeof siteContent.welcomeEmailSpotifyBtnLabel === 'string' ? siteContent.welcomeEmailSpotifyBtnLabel.trim().slice(0, 80) : '', welcomeEmailAppleBtnLabel: typeof siteContent.welcomeEmailAppleBtnLabel === 'string' ? siteContent.welcomeEmailAppleBtnLabel.trim().slice(0, 80) : '', welcomeEmailStartHereLinkLabel: typeof siteContent.welcomeEmailStartHereLinkLabel === 'string' ? siteContent.welcomeEmailStartHereLinkLabel.trim().slice(0, 80) : '', studyWelcomeEmailSubject: typeof siteContent.studyWelcomeEmailSubject === 'string' ? siteContent.studyWelcomeEmailSubject.trim().slice(0, 200) : '', studyWelcomeEmailBody: typeof siteContent.studyWelcomeEmailBody === 'string' ? siteContent.studyWelcomeEmailBody.trim().slice(0, 1000) : '', studyWelcomeEmailCtaLabel: typeof siteContent.studyWelcomeEmailCtaLabel === 'string' ? siteContent.studyWelcomeEmailCtaLabel.trim().slice(0, 80) : '', studyWelcomeEmailCtaPath: typeof siteContent.studyWelcomeEmailCtaPath === 'string' ? siteContent.studyWelcomeEmailCtaPath.trim().slice(0, 200) : '', studyWelcomeEmailSignoff: typeof siteContent.studyWelcomeEmailSignoff === 'string' ? siteContent.studyWelcomeEmailSignoff.trim().slice(0, 200) : '', studyDeletedEmailSubject: typeof siteContent.studyDeletedEmailSubject === 'string' ? siteContent.studyDeletedEmailSubject.trim().slice(0, 200) : '', studyDeletedEmailBody: typeof siteContent.studyDeletedEmailBody === 'string' ? siteContent.studyDeletedEmailBody.trim().slice(0, 1000) : '', studyDeletedEmailCtaLabel: typeof siteContent.studyDeletedEmailCtaLabel === 'string' ? siteContent.studyDeletedEmailCtaLabel.trim().slice(0, 80) : '', studyDeletedEmailCtaPath: typeof siteContent.studyDeletedEmailCtaPath === 'string' ? siteContent.studyDeletedEmailCtaPath.trim().slice(0, 200) : '', studyDeletedEmailSignoff: typeof siteContent.studyDeletedEmailSignoff === 'string' ? siteContent.studyDeletedEmailSignoff.trim().slice(0, 200) : '', studyReminderEmailSubjectPrefix: typeof siteContent.studyReminderEmailSubjectPrefix === 'string' ? siteContent.studyReminderEmailSubjectPrefix.trim().slice(0, 200) : '', studyReminderEmailBody: typeof siteContent.studyReminderEmailBody === 'string' ? siteContent.studyReminderEmailBody.trim().slice(0, 1000) : '', studyReminderEmailCtaLabel: typeof siteContent.studyReminderEmailCtaLabel === 'string' ? siteContent.studyReminderEmailCtaLabel.trim().slice(0, 80) : '', studyReminderEmailSignoff: typeof siteContent.studyReminderEmailSignoff === 'string' ? siteContent.studyReminderEmailSignoff.trim().slice(0, 200) : '', emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '', emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '', emailChangeCtaLabel: typeof siteContent.emailChangeCtaLabel === 'string' ? siteContent.emailChangeCtaLabel.trim().slice(0, 80) : '', seo: { title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title, description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description, ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle, ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription, ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage, canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl, robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy, sitemapPaths: Array.isArray(seo.sitemapPaths) ? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean) : [...DEFAULT_SEO.sitemapPaths], }, legal: { privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle, privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0 ? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20) : [...DEFAULT_LEGAL.privacyBody], termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle, termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0 ? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20) : [...DEFAULT_LEGAL.termsBody], }, } } export function escapeXml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export function buildAbsoluteUrl(baseUrl, maybeRelativePath) { const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root const value = maybeRelativePath.trim() if (/^https?:\/\//i.test(value)) return value if (value.startsWith('/')) return `${root}${value}` return `${root}/${value}` } export function injectSeoIntoHtml(html, siteContent) { const seo = siteContent?.seo ?? DEFAULT_SEO const title = seo.title || DEFAULT_SEO.title const description = seo.description || DEFAULT_SEO.description const ogTitle = seo.ogTitle || title const ogDescription = seo.ogDescription || description const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/') const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage) const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy return html .replace(/