refractor server.js
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import express from 'express'
|
||||
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,
|
||||
} 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) {
|
||||
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: /', `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}`)
|
||||
|
||||
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))
|
||||
|
||||
// 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.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>`)
|
||||
})
|
||||
|
||||
// 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.')
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user