Publish Container / build (push) Successful in 16m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
import express from 'express'
|
|
import { applyFeedRelease } from '../feed-release.js'
|
|
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,
|
|
DATA_FILE,
|
|
} from '../config.js'
|
|
import { state } from '../state.js'
|
|
import { loadSiteContentFile } from '../data.js'
|
|
import { sanitizeRedirectRules } from '../study-helpers.js'
|
|
import { fetchAllEpisodes } from './episodes.js'
|
|
import { fetchNuggetsEpisodes } from './nuggets.js'
|
|
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
|
|
|
|
// Build a regex that matches scripture references like "Titus 1:5-9" or "1 Cor 13:4-7"
|
|
// out of plain text. Sorted longest-first so "1 Corinthians" beats "1 Cor".
|
|
const _refPatterns = []
|
|
for (const b of BIBLE_BOOKS) {
|
|
_refPatterns.push(b.name, ...(b.aliases ?? []))
|
|
}
|
|
_refPatterns.sort((a, z) => z.length - a.length)
|
|
const _bookPart = _refPatterns.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
const SCRIPTURE_REF_RE = new RegExp(
|
|
`(${_bookPart})\\s+(\\d+):(\\d+)(?:-((?:\\d+):)?(\\d+))?`,
|
|
'gi',
|
|
)
|
|
|
|
function extractScriptureRefs(text) {
|
|
const refs = []
|
|
const seen = new Set()
|
|
for (const m of String(text || '').matchAll(SCRIPTURE_REF_RE)) {
|
|
const ref = m[0].trim()
|
|
const key = ref.toLowerCase()
|
|
if (!seen.has(key)) { seen.add(key); refs.push(ref) }
|
|
}
|
|
return refs
|
|
}
|
|
|
|
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 = {} }
|
|
}
|
|
content = await applyFeedRelease(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: /', `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 : []),
|
|
...(Array.isArray(state.cachedSiteContent?.finishedBooks) ? state.cachedSiteContent.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
|
]
|
|
for (const ep of highlights) {
|
|
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
|
}
|
|
// The standalone gospel page
|
|
if (!urls.includes(`${root}/salvation`)) urls.push(`${root}/salvation`)
|
|
// Newsletter archive + published issues
|
|
urls.push(`${root}/newsletter`)
|
|
for (const issue of state.newsletterIssues) {
|
|
if (issue.slug && issue.public !== false) urls.push(`${root}/newsletter/${encodeURIComponent(issue.slug)}`)
|
|
}
|
|
|
|
const xml = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
|
|
'</urlset>',
|
|
].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
|
|
}
|
|
// Same source as /api/questions, so a freshly published answer's share link works straight away
|
|
const sourceQuestions = state.draftQuestions ?? 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(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>${ogTitle} — Verse by Verse with Nate</title>
|
|
<meta name="description" content="${ogDescription}"/>
|
|
<meta property="og:type" content="article"/>
|
|
<meta property="og:site_name" content="Verse by Verse with Nate"/>
|
|
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
|
|
<meta property="og:title" content="${ogTitle}"/>
|
|
<meta property="og:description" content="${ogDescription}"/>
|
|
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
|
|
<meta property="og:image:alt" content="${ogTitle}"/>
|
|
<meta name="twitter:card" content="summary_large_image"/>
|
|
<meta name="twitter:title" content="${ogTitle}"/>
|
|
<meta name="twitter:description" content="${ogDescription}"/>
|
|
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
|
|
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
|
|
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
|
|
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
|
|
</head>
|
|
<body></body>
|
|
</html>`)
|
|
})
|
|
|
|
// Scripture index — every passage covered across released study lessons and podcast episodes
|
|
app.get('/api/public/scripture-index', async (_req, res) => {
|
|
const content = await applyFeedRelease(state.cachedSiteContent)
|
|
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
|
const allEpisodes = [
|
|
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
|
...(Array.isArray(content?.finishedBooks) ? content.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
|
]
|
|
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}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Extract scripture refs from main podcast episode descriptions
|
|
try {
|
|
const mainEps = await fetchAllEpisodes()
|
|
for (const ep of mainEps) {
|
|
if (!ep.description) continue
|
|
const refs = extractScriptureRefs(ep.description)
|
|
for (const ref of refs) {
|
|
const title = ep.episode ? `Episode ${ep.episode}: ${ep.title}` : ep.title
|
|
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: ep.link || '/episodes' })
|
|
}
|
|
}
|
|
} catch { /* keep going if RSS is down */ }
|
|
|
|
// Extract scripture refs from Nuggets episode descriptions
|
|
try {
|
|
const nuggetsEps = await fetchNuggetsEpisodes()
|
|
for (const ep of nuggetsEps) {
|
|
if (!ep.description) continue
|
|
const refs = extractScriptureRefs(ep.description)
|
|
for (const ref of refs) {
|
|
const title = ep.episode ? `Nuggets Ep. ${ep.episode}: ${ep.title}` : ep.title
|
|
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: '/nuggets' })
|
|
}
|
|
}
|
|
} catch { /* keep going if RSS is down */ }
|
|
|
|
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.')
|
|
}
|
|
})
|
|
}
|