layout gained three optional fields: ogType, ogImage, headExtra. The
listing detail page uses all three — ogType=article, ogImage=the icon
URL (fallback to the Prometheus favicon), and headExtra for schema.org
SoftwareApplication JSON-LD. Google renders this as a rich card for
app results; the SEO uplift is real and the data is straightforward.
JSON-LD payload composed in service/seo.js:
@type SoftwareApplication, name, description, softwareVersion,
applicationCategory (mapped from Prometheus's slug to schema's name),
url, downloadUrl, fileSize, offers (free), author (BNS name as org),
image (icon when present), contentRating ("ClamAV clean" on a clean
scan).
OG defaults for every page: og:type (website unless overridden),
og:image (favicon unless overridden). The canonical URL stays
prometheus.x on both the live service and the apps.silentmode.st
mirror — crawlers that land on the mirror attribute the content to
the real address, matching the pattern [[silentmode-site-url-scheme]]
describes.
deploy/apps-silentmode-st.conf is the angie vhost for the mirror:
reverse-proxies to 127.0.0.1:3700, declines /admin (operator always
goes direct), 2 GiB client_max_body_size for POST /api/upload,
proxy_request_buffering off so a slow upload does not spool to disk.
Reuses the main silentmode.st certificate (SAN *.silentmode.st; if
that's not set up yet, request one and reload before enabling).
111 lines
3.9 KiB
JavaScript
111 lines
3.9 KiB
JavaScript
/**
|
|
* Shared document shell for every rendered page.
|
|
*
|
|
* A page function returns `{title, description, body}`; `layout()` wraps it in
|
|
* the `<html>` document with the right meta, topnav, and footer. The brand mark
|
|
* is inlined as SVG so pages do not fetch an image to render a 20-pixel logo.
|
|
*/
|
|
|
|
const BRAND_MARK = `<svg width="18" height="18" viewBox="0 0 64 64" style="vertical-align:-4px;margin-right:6px" aria-hidden="true">
|
|
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
|
<path d="M32 10 C 22 22, 24 32, 32 36 C 40 32, 42 22, 32 10 Z" fill="#d6ff3d"/>
|
|
<path d="M26 36 C 20 44, 22 54, 32 56 C 42 54, 44 44, 38 36 C 36 42, 28 42, 26 36 Z" fill="#d6ff3d" opacity=".75"/>
|
|
</svg>`;
|
|
|
|
const NAV = [
|
|
{ href: '/browse', label: 'Browse' },
|
|
{ href: '/submit', label: 'Submit' },
|
|
{ href: '/publish', label: 'How publishing works' },
|
|
{ href: '/boundaries', label: 'Boundaries' },
|
|
];
|
|
|
|
/**
|
|
* Compose a full HTML document from a page.
|
|
*
|
|
* @param {{title: string, description: string, body: string, here?: string}} page
|
|
* @returns {string} the HTML document, ready to send.
|
|
*/
|
|
export function layout(page) {
|
|
const {
|
|
title, description, body, here = '',
|
|
ogType = 'website',
|
|
ogImage = 'https://prometheus.x/static/favicon.svg',
|
|
headExtra = '',
|
|
} = page;
|
|
|
|
// The canonical URL is always on prometheus.x — never the IP+port the server
|
|
// actually listens on, and never the SEO mirror at apps.silentmode.st, even
|
|
// when the mirror is what served this request. Readers who followed a link
|
|
// from search land on the mirror; the canonical sends search engines back to
|
|
// the real address and keeps the scorecard on prometheus.x.
|
|
const canonical = `https://prometheus.x${here}`;
|
|
|
|
const navHtml = NAV.map(n => {
|
|
const classAttr = n.href === here ? ' class="here"' : '';
|
|
return `<a href="${n.href}"${classAttr}>${n.label}</a>`;
|
|
}).join('\n ');
|
|
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>${escapeHtml(title)} — Prometheus</title>
|
|
<meta name="description" content="${escapeHtml(description)}">
|
|
<link rel="canonical" href="${canonical}">
|
|
<meta property="og:type" content="${escapeHtml(ogType)}">
|
|
<meta property="og:site_name" content="Prometheus">
|
|
<meta property="og:title" content="${escapeHtml(title)} — Prometheus">
|
|
<meta property="og:description" content="${escapeHtml(description)}">
|
|
<meta property="og:url" content="${canonical}">
|
|
<meta property="og:image" content="${escapeHtml(ogImage)}">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
<link rel="stylesheet" href="/static/style.css?v=1">
|
|
${headExtra}
|
|
</head>
|
|
<body>
|
|
|
|
<nav class="topnav">
|
|
<a class="brand" href="/">${BRAND_MARK}Prometheus<span class="g">.X</span></a>
|
|
${navHtml}
|
|
</nav>
|
|
|
|
${body}
|
|
|
|
<footer>
|
|
<div class="fine">
|
|
<a href="//theseus.x/">Theseus</a> ·
|
|
<a href="//sirius.x/">Sirius</a> ·
|
|
<a href="//ariadne.x/">Ariadne</a> ·
|
|
<a href="//hephaestus.x/">Hephaestus</a>
|
|
<br>
|
|
Those are BCNR names, not domains — they resolve in a client that speaks
|
|
the naming system, and nowhere else.
|
|
</div>
|
|
<div class="fine">
|
|
Named for Prometheus, who gave fire to humans without asking the gods.
|
|
Distinct from the well-known Prometheus monitoring project — both take
|
|
the name from the same myth; neither has anything to do with the other.
|
|
</div>
|
|
<div class="fine">
|
|
Silent Mode — a Deviant project. MPL-2.0.
|
|
</div>
|
|
</footer>
|
|
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* Minimal HTML escape for text that goes into attributes or text nodes.
|
|
* Not for anything that must survive as markup; page bodies ship as-is.
|
|
*/
|
|
export function escapeHtml(s) {
|
|
return String(s)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|