import express from 'express'
import path from 'node:path'
import { readFile } from 'node:fs/promises'
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
import {
DIST_DIR,
DIST_IMAGES_DIR,
PUBLIC_IMAGES_DIR,
UPLOADS_DIR,
INDEX_FILE,
DEFAULT_SEO,
APP_VERSION,
GIT_COMMIT,
} from '../config.js'
import { state } from '../state.js'
import { loadSiteContentFile } from '../data.js'
import { DATA_FILE } from '../config.js'
import { sanitizeRedirectRules } from '../study-helpers.js'
export function register(app) {
const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html')
app.get('/api/version', (_req, res) => {
res.json({ version: APP_VERSION, commit: GIT_COMMIT })
})
app.get('/robots.txt', async (_req, res) => {
let content = state.cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch { content = {} }
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
res.type('text/plain').send(
['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'),
)
})
app.get('/sitemap.xml', async (_req, res) => {
let content = state.cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch { content = {} }
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
? seo.sitemapPaths
: DEFAULT_SEO.sitemapPaths
const urls = paths
.map(item => normalizeSitemapPath(item))
.filter(Boolean)
.map(item => `${root}${item}`)
// Dynamic study lesson URLs
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
const now = new Date()
for (const study of studies) {
if (!study.slug || !Array.isArray(study.sections)) continue
for (const section of study.sections) {
if (section.id && section.releasedAt && new Date(section.releasedAt) <= now) {
urls.push(`${root}/study/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`)
}
}
}
// Dynamic episode highlight URLs
const highlights = Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []
for (const ep of highlights) {
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
}
const xml = [
'',
'',
...urls.map(url => ` ${escapeXml(url)}`),
'',
].join('\n')
res.type('application/xml').send(xml)
})
// Redirect rules middleware
app.use((req, res, next) => {
const rules = sanitizeRedirectRules(state.cachedSiteContent?.redirects)
const match = rules.find(rule => rule.path === req.path)
if (!match) { next(); return }
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
})
// Static files
app.use('/images', express.static(DIST_IMAGES_DIR))
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
app.use('/uploads', express.static(UPLOADS_DIR))
// Explicitly serve salvation page on both paths without relying on slash redirects.
app.get(['/salvation', '/salvation/'], (_req, res, next) => {
res.sendFile(SALVATION_INDEX_FILE, err => {
if (err) next()
})
})
// Social share stub for questions
app.get('/questions/share/:id', (req, res) => {
const id = req.params.id
if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions'); return
}
const sourceQuestions = state.questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) {
res.redirect(302, '/questions'); return
}
const BASE = 'https://versebyversewithnate.us'
const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}`
const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}`
const ogTitle = escapeHtml(question.question.length > 100
? `${question.question.slice(0, 97)}…`
: question.question)
const answerSnippet = question.answer.replace(/\n+/g, ' ').trim()
const ogDescription = escapeHtml(answerSnippet.length > 200
? `${answerSnippet.slice(0, 197)}…`
: answerSnippet)
const ogImage = `${BASE}/images/banner.png`
res.type('html').send(`
${ogTitle} — Verse by Verse with Nate
`)
})
// Scripture index — every passage covered across released study lessons and podcast episodes
app.get('/api/public/scripture-index', (_req, res) => {
const content = state.cachedSiteContent
const allStudies = Array.isArray(content?.studies) ? content.studies : []
const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const cutoff = new Date()
const entries = []
for (const s of allStudies) {
if (!s.slug || !Array.isArray(s.sections)) continue
const bookName = s.bibleBook || s.slug
for (const sec of s.sections) {
if (!sec.id || !sec.reference || !sec.releasedAt) continue
if (new Date(sec.releasedAt) > cutoff) continue
entries.push({
reference: `${bookName} ${sec.reference}`,
sourceType: 'study',
sourceTitle: s.title || s.slug,
sectionTitle: sec.title || '',
url: `/study/${s.slug}/${sec.id}`,
})
}
}
for (const ep of allEpisodes) {
if (!ep.id || !Array.isArray(ep.showNotesScripture)) continue
for (const ref of ep.showNotesScripture) {
if (!ref || !ref.trim()) continue
entries.push({
reference: ref.trim(),
sourceType: 'episode',
sourceTitle: ep.episodeNumber ? `Episode ${ep.episodeNumber}: ${ep.title}` : (ep.title || 'Episode'),
url: `/episodes/${ep.id}`,
})
}
}
res.json({ entries })
})
// SPA static files and fallback
app.use(express.static(DIST_DIR))
app.use(async (_req, res) => {
try {
const html = await readFile(INDEX_FILE, 'utf8')
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
} catch {
res.status(503).send('Frontend build not found. Run "npm run build" first.')
}
})
}