Add sitemap

This commit is contained in:
Raven Scott
2026-07-11 19:57:20 -04:00
parent 0f25e9f787
commit 77991dd871
3 changed files with 182 additions and 122 deletions
+8
View File
@@ -31,6 +31,14 @@ When peardocks install process changes, update:
- Upstream: `https://git.ssh.surf/api/v1/repos/snxraven/peardock/releases`
- Web: `https://git.ssh.surf/snxraven/peardock/releases`
## Sitemap
- Dynamic: `GET /sitemap.xml` (generated from `dist/` HTML by `server.mjs`)
- Google-compatible urlset with `loc`, `lastmod`, `changefreq`, `priority`, and `xhtml:link` hreflang
- Excludes noindex stubs (e.g. `/legal/cookies`)
- Origin: `SITE_ORIGIN` env (default `https://peardock.boats`)
- Listed in `robots.txt` as `Sitemap: https://peardock.boats/sitemap.xml`
## Dev
```bash
-118
View File
@@ -1,118 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://peardock.boats/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://peardock.boats/download</loc>
<changefreq>weekly</changefreq>
<priority>0.95</priority>
</url>
<url>
<loc>https://peardock.boats/releases</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://peardock.boats/learn/</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://peardock.boats/community</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://peardock.boats/docs/</loc>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://peardock.boats/docs/quickstart</loc>
<changefreq>monthly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://peardock.boats/docs/architecture</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://peardock.boats/docs/operator</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://peardock.boats/docs/holesail</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://peardock.boats/docs/security</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://peardock.boats/docs/api</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://peardock.boats/docs/release</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://peardock.boats/docs/faq</loc>
<changefreq>monthly</changefreq>
<priority>0.75</priority>
</url>
<url>
<loc>https://peardock.boats/legal/</loc>
<changefreq>yearly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://peardock.boats/legal/terms</loc>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://peardock.boats/legal/eula</loc>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://peardock.boats/legal/privacy</loc>
<changefreq>yearly</changefreq>
<priority>0.45</priority>
</url>
<url>
<loc>https://peardock.boats/legal/license</loc>
<changefreq>yearly</changefreq>
<priority>0.45</priority>
</url>
<url>
<loc>https://peardock.boats/legal/aup</loc>
<changefreq>yearly</changefreq>
<priority>0.35</priority>
</url>
<url>
<loc>https://peardock.boats/legal/copyright</loc>
<changefreq>yearly</changefreq>
<priority>0.35</priority>
</url>
<url>
<loc>https://peardock.boats/legal/disclaimer</loc>
<changefreq>yearly</changefreq>
<priority>0.35</priority>
</url>
<url>
<loc>https://peardock.boats/legal/security</loc>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
</urlset>
+174 -4
View File
@@ -1,7 +1,8 @@
/**
* PearDock website server: static dist/ + Gitea releases API proxy.
* PearDock website server: static dist/ + Gitea releases API proxy + dynamic sitemap.
*
* Proxies:
* Routes:
* GET /sitemap.xml → Google-compatible sitemap (generated from dist/)
* GET /api/releases → list releases
* GET /api/releases/tags/:tag → single release by tag
* GET /api/releases/latest → latest non-draft (or rolling if present)
@@ -9,6 +10,7 @@
* Env:
* PORT (default 4173)
* DIST (default ./dist)
* SITE_ORIGIN (default https://peardock.boats)
* GITEA_API (default https://git.ssh.surf/api/v1/repos/snxraven/peardock)
*/
import http from 'node:http'
@@ -19,11 +21,18 @@ import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PORT = Number(process.env.PORT || 4173)
const DIST = path.resolve(process.env.DIST || path.join(__dirname, 'dist'))
const SITE_ORIGIN = String(process.env.SITE_ORIGIN || 'https://peardock.boats').replace(
/\/+$/,
''
)
const GITEA_API =
process.env.GITEA_API || 'https://git.ssh.surf/api/v1/repos/snxraven/peardock'
const GITEA_WEB =
process.env.GITEA_WEB || 'https://git.ssh.surf/snxraven/peardock'
/** Paths that must not appear in the public sitemap (noindex / redirect stubs). */
const SITEMAP_EXCLUDE = new Set(['/legal/cookies'])
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
@@ -176,6 +185,160 @@ function safeJoin(root, reqPath) {
return full
}
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/** dist-relative path → public URL path (clean URLs used by the site). */
function distHtmlToUrlPath(relPosix) {
const rel = relPosix.replace(/\\/g, '/').replace(/^\//, '')
if (rel === 'index.html') return '/'
if (rel.endsWith('/index.html')) {
return `/${rel.slice(0, -'index.html'.length)}`
}
if (rel.endsWith('.html')) return `/${rel.slice(0, -'.html'.length)}`
return `/${rel}`
}
function walkHtmlFiles(dir, baseRel = '') {
/** @type {{ rel: string, abs: string, mtime: Date }[]} */
const out = []
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const ent of entries) {
if (ent.name.startsWith('.')) continue
if (ent.name === 'assets' || ent.name === 'node_modules') continue
const abs = path.join(dir, ent.name)
const rel = baseRel ? `${baseRel}/${ent.name}` : ent.name
if (ent.isDirectory()) {
out.push(...walkHtmlFiles(abs, rel))
continue
}
if (ent.isFile() && ent.name.endsWith('.html')) {
let mtime = new Date()
try {
mtime = fs.statSync(abs).mtime
} catch {
/* keep now */
}
out.push({ rel: rel.replace(/\\/g, '/'), abs, mtime })
}
}
return out
}
function sitemapPriority(urlPath) {
if (urlPath === '/') return '1.0'
if (urlPath === '/download' || urlPath === '/releases') return '0.95'
if (urlPath === '/learn/' || urlPath === '/docs/') return '0.9'
if (urlPath === '/community') return '0.8'
if (urlPath.startsWith('/docs/')) return '0.85'
if (urlPath === '/legal/') return '0.5'
if (urlPath.startsWith('/legal/')) return '0.4'
return '0.7'
}
function sitemapChangefreq(urlPath) {
if (urlPath === '/releases') return 'daily'
if (urlPath === '/' || urlPath === '/download' || urlPath.startsWith('/docs')) return 'weekly'
if (urlPath.startsWith('/legal')) return 'yearly'
return 'monthly'
}
/**
* Build a Google Searchcompatible sitemap 0.9 document from dist HTML pages.
* @see https://www.sitemaps.org/protocol.html
*/
function buildSitemapXml() {
const pages = walkHtmlFiles(DIST)
/** @type {Map<string, { loc: string, lastmod: string, changefreq: string, priority: string }>} */
const byPath = new Map()
for (const page of pages) {
const urlPath = distHtmlToUrlPath(page.rel)
const excludeKey = urlPath.replace(/\/+$/, '') || '/'
if (SITEMAP_EXCLUDE.has(excludeKey) || SITEMAP_EXCLUDE.has(urlPath)) continue
const lastmod = page.mtime.toISOString().slice(0, 10) // W3C date (YYYY-MM-DD)
const existing = byPath.get(urlPath)
if (existing && existing.lastmod >= lastmod) continue
byPath.set(urlPath, {
loc: SITE_ORIGIN + (urlPath === '/' ? '/' : urlPath),
lastmod,
changefreq: sitemapChangefreq(urlPath),
priority: sitemapPriority(urlPath),
})
}
// Stable sort: home first, then path
const entries = [...byPath.values()].sort((a, b) => {
if (a.loc === `${SITE_ORIGIN}/`) return -1
if (b.loc === `${SITE_ORIGIN}/`) return 1
return a.loc.localeCompare(b.loc)
})
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
' xmlns:xhtml="http://www.w3.org/1999/xhtml">',
]
for (const e of entries) {
lines.push(' <url>')
lines.push(` <loc>${escapeXml(e.loc)}</loc>`)
lines.push(` <lastmod>${escapeXml(e.lastmod)}</lastmod>`)
lines.push(` <changefreq>${escapeXml(e.changefreq)}</changefreq>`)
lines.push(` <priority>${escapeXml(e.priority)}</priority>`)
// Self-referencing hreflang (single-language site)
lines.push(
` <xhtml:link rel="alternate" hreflang="en" href="${escapeXml(e.loc)}" />`
)
lines.push(
` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(e.loc)}" />`
)
lines.push(' </url>')
}
lines.push('</urlset>')
lines.push('')
return lines.join('\n')
}
function serveSitemap(req, res) {
try {
const xml = buildSitemapXml()
const headers = {
'Content-Type': 'application/xml; charset=utf-8',
// Short cache so new pages appear soon after deploys
'Cache-Control': 'public, max-age=300',
'X-Robots-Tag': 'noindex', // the sitemap document itself need not be indexed as a page
}
if (req.method === 'HEAD') {
res.writeHead(200, {
...headers,
'Content-Length': Buffer.byteLength(xml),
})
res.end()
return
}
send(res, 200, xml, headers)
} catch (err) {
console.error('[sitemap]', err)
send(res, 500, 'Sitemap generation failed', {
'Content-Type': 'text/plain; charset=utf-8',
})
}
}
function resolveStatic(urlPath) {
let p = urlPath
if (p.endsWith('/')) p += 'index.html'
@@ -232,7 +395,7 @@ const server = http.createServer(async (req, res) => {
// Health
if (url.pathname === '/api/health') {
sendJson(res, 200, { ok: true, gitea: GITEA_API })
sendJson(res, 200, { ok: true, gitea: GITEA_API, site: SITE_ORIGIN })
return
}
@@ -246,12 +409,19 @@ const server = http.createServer(async (req, res) => {
return
}
// Dynamic sitemap (overrides any static dist/sitemap.xml)
if (url.pathname === '/sitemap.xml' || url.pathname === '/sitemap.xml/') {
serveSitemap(req, res)
return
}
serveStatic(req, res, url)
})
server.listen(PORT, () => {
console.log(`[peardock-website] http://127.0.0.1:${PORT}`)
console.log(`[peardock-website] dist=${DIST}`)
console.log(`[peardock-website] origin=${SITE_ORIGIN}`)
console.log(`[peardock-website] gitea=${GITEA_API}`)
console.log(`[peardock-website] releases UI=/releases api=/api/releases`)
console.log(`[peardock-website] sitemap=/sitemap.xml releases=/releases api=/api/releases`)
})