scripts/snapshot.mjs renders every public page (home, browse, publish, boundaries, 8 categories, every active listing) to <out>/<path>/index.html, plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a directory next to the service AND uploading via `aws s3 sync` are two flags on the same command: --out to pick the directory, --upload to push. Live routes added for /sitemap.xml and /robots.txt so a crawler hitting the live service sees the same enumeration the snapshot bakes into the bucket. renderSitemap / renderRobots in service/seo.js are the single rendering path both call sites use. Scope deliberately limited to the browse surface — /submit and the API routes depend on the live backend and were never going to work on a static mirror. The browse surface IS what the fallback is for: readers survive an outage, publishers can't. systemd unit + timer under deploy/ run the snapshot weekly on Sunday 04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a reboot catches up. Unit uses the sia-storage AWS profile per [[sia-storage-credentials-location]] and the per-process endpoint URL baked in via Environment=. Local dry-run against an empty DB produced 14 HTML files + sitemap + robots + the two assets.
99 lines
3 KiB
JavaScript
99 lines
3 KiB
JavaScript
/**
|
|
* SEO artefacts — sitemap and robots.
|
|
*
|
|
* sitemap.xml enumerates every crawlable URL. The snapshot writes it to the
|
|
* bucket root; the live service serves the same file on request so a crawler
|
|
* hitting prometheus.x (via the apps.silentmode.st mirror in production) sees
|
|
* the same contents the snapshot does.
|
|
*
|
|
* robots.txt is the usual shape: allow everything except the admin and the
|
|
* raw API routes, which have no public content.
|
|
*/
|
|
|
|
const BASE = process.env.PUBLIC_BASE || 'https://prometheus.x';
|
|
|
|
/**
|
|
* @param {{categories: string[], listings: Array<{publisher: string, slug: string, lastmod?: string}>}} data
|
|
*/
|
|
export function renderSitemap({ categories, listings }) {
|
|
const now = new Date().toISOString();
|
|
const urls = [
|
|
{ loc: '/', lastmod: now, priority: '1.0' },
|
|
{ loc: '/browse', lastmod: now, priority: '0.9' },
|
|
{ loc: '/publish', lastmod: now, priority: '0.6' },
|
|
{ loc: '/boundaries', lastmod: now, priority: '0.5' },
|
|
{ loc: '/submit', lastmod: now, priority: '0.4' },
|
|
...categories.map((slug) => ({
|
|
loc: `/c/${slug}`, lastmod: now, priority: '0.7',
|
|
})),
|
|
...listings.map((l) => ({
|
|
loc: `/app/${l.publisher}/${l.slug}`,
|
|
lastmod: l.lastmod || now,
|
|
priority: '0.8',
|
|
})),
|
|
];
|
|
|
|
const body = urls.map((u) =>
|
|
` <url><loc>${BASE}${u.loc}</loc><lastmod>${u.lastmod}</lastmod><priority>${u.priority}</priority></url>`
|
|
).join('\n');
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
${body}
|
|
</urlset>
|
|
`;
|
|
}
|
|
|
|
export function renderRobots() {
|
|
return `User-agent: *
|
|
Allow: /
|
|
Disallow: /admin
|
|
Disallow: /admin/
|
|
Disallow: /api/
|
|
Disallow: /.well-known/
|
|
|
|
Sitemap: ${BASE}/sitemap.xml
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* schema.org JSON-LD for a listing — Google renders a rich card for apps.
|
|
* Returns the raw JSON string; the template wraps it in a <script> tag.
|
|
*/
|
|
export function softwareApplicationLd({ listing, scanVerdict }) {
|
|
const payload = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'SoftwareApplication',
|
|
name: listing.name,
|
|
description: listing.description,
|
|
softwareVersion: listing.version,
|
|
applicationCategory: humanCategory(listing.category),
|
|
url: `${BASE}/app/${listing.publisher}/${listing.slug}`,
|
|
downloadUrl: listing.download_url,
|
|
fileSize: `${listing.download_size} B`,
|
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
author: {
|
|
'@type': 'Organization',
|
|
name: listing.publisher,
|
|
},
|
|
};
|
|
if (listing.icon) payload.image = listing.icon;
|
|
if (scanVerdict === 'clean') {
|
|
payload.contentRating = 'ClamAV clean';
|
|
}
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
function humanCategory(slug) {
|
|
const map = {
|
|
communication: 'CommunicationApplication',
|
|
privacy: 'SecurityApplication',
|
|
wallets: 'FinanceApplication',
|
|
games: 'GameApplication',
|
|
devtools: 'DeveloperApplication',
|
|
media: 'MultimediaApplication',
|
|
utilities: 'UtilitiesApplication',
|
|
other: 'ApplicationSuite',
|
|
};
|
|
return map[slug] || 'ApplicationSuite';
|
|
}
|