Add sitemap
This commit is contained in:
@@ -31,6 +31,14 @@ When peardock’s 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
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** 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 Search–compatible 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`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user