Publish Container / docker (push) Successful in 15m2s
Previously it just grouped study lessons under each study (essentially another list of lessons) and ignored podcast episodes entirely. Now it parses every reference from studies and episode show notes, merges entries that point at the same verse(s) into one row with links to all sources, and groups rows by book in canonical Bible order. References that don't parse (e.g. whole-chapter refs) fall into an "Other References" section instead of being dropped. Also fixes the reference passed to the index for studies without a bibleBook set — it was falling back to the study's marketing title instead of its slug, producing unparseable references. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
214 lines
7.6 KiB
JavaScript
214 lines
7.6 KiB
JavaScript
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 = [
|
|
'<?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
|
|
}
|
|
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(`<!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', (_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.')
|
|
}
|
|
})
|
|
}
|