SEO and analytics — being found, and being counted
How every page on the hub gets its title, description, canonical link, Open Graph card, Twitter card tags and JSONLD, what a crawler is told to read, and what t
How every page on the hub gets its title, description, canonical link, Open Graph card, Twitter card tags and JSON-LD, what a crawler is told to read, and what the site records about a visit. Also the server-rendered content that makes all of it work without JavaScript.
Why this page exists
Every page is a real URL with a real <title>, a description, a canonical link, an Open Graph card, Twitter card tags and a JSON-LD block, and every public profile and guide is in the sitemap.
The pages themselves are static files in public/. The server reads the same file and injects the SEO block at a <!--SEO:META--> placeholder, so there is one source of truth for both the page and its metadata: the markup a reader gets and the markup a crawler parses come out of the same file, which means they cannot drift apart.
serveShell()
routes/seo.js:1030-1066. The shared prologue for /forum, /forum/t/:id and /updates, which are the same shape — a static shell with a <base>, a generated title and description, and an optional index directive.
It does five things:
- Injects
<base href="<SITE_URL>/">right after the charset meta. - Sets
data-baseon<body>viasetHtmlAttr(), so client scripts never have to guess the mount prefix. - Rewrites
<title>with a non-greedy match over its whole element. - Drops the template's own description and robots meta.
- Injects canonical, OG and Twitter tags at
<!--SEO:META-->.
Step 2 exists because the client scripts used to guess the prefix from the first path segment, which is wrong at the root: on /forum they read the site as living under /forum and every API call went one segment too deep. See Project structure for how the same fact is shared by lib/paths.js.
Step 4 is not optional. The templates ship a generic description so the document is complete before anything is injected, and whatever this route decides has to replace it, not sit next to it. Two description tags is a document that says two different things, and which one a crawler uses is its own business — permanently.
serveShell() also takes a noindex flag. When set it removes the template's robots meta and emits noindex,follow in place of the description meta. None of the three current callers passes it — /forum, /forum/t/:id and /updates are all indexable — but the parameter exists so a page can opt out without being given its own route.
setHtmlAttr()
routes/seo.js:81-95. Sets one attribute on an opening tag whatever else is already on it.
It exists because html.replace('<body>', …) is a trap. The editor's body is <body data-app-shell>, so the literal <body> is nowhere in the file, the replace quietly did nothing, and the recorder — which gates on document.body.dataset.canRecord — exited before it could show its button. To an admin that reads as "the feature is gone", with nothing anywhere reporting a failure.
The function matches the tag with /<body\b[^>]*>/i and rewrites its attribute list, so a page can carry as many attributes as it likes and the injection still lands. It is used for data-base (serveShell) and data-can-record on /editor, and it throws rather than silently no-opping if the opening tag is not found.
dropStaticDescription()
routes/seo.js:655-657:
html.replace(/[ \t]*<meta name="description"[^>]*>\r?\n/, '')
Used by every route whose description is generated: /oled, /check, /editor, /records, /profiles/hub, /profiles/:id, /users/:id, and serveShell() itself. The front page is the one page that does not call it, because the route's description is the same sentence the template already carries.
jsonLd()
routes/seo.js:97-103. JSON.stringify, then every <, > and & in the result is replaced with the six-character escape \u003c, \u003e and \u0026 respectively:
JSON.stringify(obj)
.replace(/</g, '\u003c')
.replace(/>/g, '\u003e')
.replace(/&/g, '\u0026')
Escaping < and > is what keeps a </script> inside a user-written profile title from closing the JSON-LD block early; escaping & keeps the payload valid inside an HTML document. The result is wrapped in <script type="application/ld+json">…</script>.
All of escHtml() is applied to every interpolated value — & to &, < to <, > to >, " to " — so a title, description or username cannot break out of a content="…" attribute.
Every card is declared with og:image:width and og:image:height, taken from lib/og-card.js's WIDTH and HEIGHT exports (the profile page hard-codes 1200 and 630). A preview renderer that trusts the declaration does not have to download the image to learn its shape.
Per-page metadata
| Route | Title pattern | Description source | Robots | og:image |
JSON-LD type |
|---|---|---|---|---|---|
/ |
Swiftpoint Profiles — Community Hub for the Swiftpoint Z3 |
SITE_DESC, constant |
indexable | /og-image.png |
WebSite |
/profiles/hub |
Community profiles — Swiftpoint Hub |
constant | indexable | /og-image/gallery.png |
ItemList |
/profiles/:id |
<title> — Swiftpoint Hub |
The profile's own, else a generated sentence | forced index,follow |
/profiles/:id/share.png |
CreativeWork |
/users/:id |
<username> — Swiftpoint Hub |
The member's bio, else a generated sentence | forced index,follow |
/og-image/user-<id>.png |
ProfilePage |
/forum |
Forum — Swiftpoint Hub |
constant | indexable | /og-image.png |
none |
/forum/t/:id |
<title> — <statusLabel> — Swiftpoint Hub |
forum.excerpt(excerpt, 150) |
indexable | /og-image.png |
DiscussionForumPosting |
/updates |
Updates — Swiftpoint Hub |
constant | indexable | /og-image.png |
none |
/editor |
Profile Builder — Swiftpoint Hub |
constant | noindex,nofollow |
/og-image/tool-editor.png |
WebApplication |
/oled |
OLED Editor — Swiftpoint Hub |
constant | indexable | /og-image/tool-oled.png |
WebApplication |
/check |
.spcf Profile Viewer — Swiftpoint Hub |
constant | noindex,nofollow |
/og-image/tool-check.png |
UtilityApplication |
/records |
Recordings — Swiftpoint Hub |
constant | noindex,nofollow |
/og-image/tool-records.png |
none |
/privacy, /imprint |
<Title> — Swiftpoint Hub |
constant | indexable | none | none |
The four noindex,nofollow pages are /editor, /check, /records and /admin. /admin emits no SEO block at all — it is behind a gate and only admins ever see it.
Two force operations are worth naming. The profile and user templates ship noindex,follow (both were preview layouts at some point), and the routes overwrite that exact string with index,follow. A replace of the literal <meta name="robots" content="noindex,follow" />, not a general rewrite.
What each JSON-LD block carries
/ — WebSite with name, alternateName: "Swiftpoint Profiles", url, description, inLanguage: "en". og:type is website, twitter:card is summary_large_image, and og:image carries og:image:width and og:image:height from lib/og-card.js's WIDTH and HEIGHT.
/profiles/hub — an ItemList of the 20 newest profiles. Each entry is a ListItem with position, url, name, an optional description capped at 200 characters, and an optional author as a Person. The gallery is drawn by the browser, so a crawler that does not run JavaScript sees no profile links on it at all; this says what the page is, using the same rows the gallery will show and no more than a search result needs. It deliberately has no og:description — the description travels in the description meta only.
/profiles/:id — CreativeWork with name, headline, description (≤155 characters), datePublished, dateModified (only when set), author as a Person, and about as a Thing naming the game when there is one. og:type is article, and og:image is the profile's own 1200×630 card.
/users/:id — ProfilePage with name, description, url, inLanguage: "en". og:type is profile, and og:image is /og-image/user-<id>.png with an og:image:alt.
/forum/t/:id — DiscussionForumPosting with headline, url, datePublished, dateModified, an author Person, and two InteractionCounters in interactionStatistic: CommentAction set to the thread's replyCount and ViewAction set to its views.
/editor, /oled, /check — WebApplication (the checker is UtilityApplication) with name, description, url and applicationCategory: "DesignApplication".
/updates — the title and description are generated by serveShell(), both constant for this page because the content changes, not the wording.
Canonical URLs
Every page emits exactly one <link rel="canonical">, and test/profile-seo.test.js asserts it.
| Case | Behaviour |
|---|---|
| Every page | One absolute canonical against SITE_URL |
/docs/<slug> with a non-canonical spelling |
301 to the canonical one, query string preserved |
/profile.html?id=<n> |
301 to /profiles/<n>; a non-numeric id 301s to / |
/oled.html |
301 to /oled |
/profiles/<n>b |
Normalised in route to /profiles/<n> — it serves the same page under the canonical canonical, no redirect |
Empty /docs/ |
301 to /docs |
The docs case exists because the engine resolves a path case-insensitively, so /docs/profile-builder/Testing and /docs/profile-builder/testing both find the same file. Only one of them is the address the sitemap, the canonical link and the index use; the other is a permanent redirect rather than a second page competing with it. The query string survives the redirect; a fragment never reaches the server.
/profiles/<n>b is handled differently on purpose. It was a legacy entry point (/57b, ?beta=1) for the redesigned layout, which is now the default, so it is rewritten inside the handler and answers with the canonical page rather than bouncing.
The sitemap
GET /sitemap.xml, served as application/xml from routes/seo.js:463-510.
| Entry | <changefreq> |
<priority> |
<lastmod> |
|---|---|---|---|
/ |
daily | 1.0 | — |
/profiles/hub |
daily | 0.9 | — |
/docs |
weekly | 0.5 | — |
every guide from eng.apiTree(), except README |
monthly | 0.7 | — |
every profile /profiles/<id> |
weekly | 0.8 | updated_at or created_at, date only |
every user /users/<id> |
weekly | 0.4 | created_at, date only |
/forum |
daily | 0.7 | — |
/updates |
weekly | 0.6 | — |
up to 100 threads /forum/t/<id> |
weekly | 0.5 | lastPostAt or createdAt, date only |
README is skipped because /docs already covers it. The 100 threads come from forum.latest(100).
Thread entries stop at 100. Threads come from forum.latest(100); profiles and users are looped in full. A very large forum is therefore only partly represented, because a sitemap is not an archive. test/profile-seo.test.js asserts every public profile is listed.
robots.txt
GET /robots.txt, text/plain, from routes/seo.js:449-461. The exact content:
User-agent: *
Allow: /
Disallow: /api/
Disallow: /admin
Disallow: /records
Sitemap: https://mecke.dev/swiftpoint/sitemap.xml
Two things this has to get right, both of which it got wrong:
It must be the same policy as the host root. A crawler that reads /robots.txt at the root and then a narrower one at /swiftpoint/ takes the narrower, so an inconsistent file here is a policy that does not apply.
And it must not be JSON. /robots.txt used to be answered by the API's catch-all 404, which is a 404 with Content-Type: application/json. A crawler reads that as "no robots here" and may treat the whole host as un-crawlable or, worse, as open.
The guides and the profile pages are not blocked: they are the reason the site exists and they are the pages being linked to.
test/robots.test.js asserts four things: that the hub serves plain text (res.type('text/plain'), and no res.json in the handler), that the policy matches the host root policy (Allow: /, the three Disallow lines, the sitemap line), that public content is not blocked, and that no dead directive is left behind — /profile.html and /update-file are explicitly asserted absent, because a Disallow for a route that no longer exists is a rule that will outlive its reason.
There is a google6a47ec6ec477047a.html verification file at the repository root, not in public/. express.static is mounted on public/ only, so that file is not reachable at any URL the server answers; only a reverse proxy or nginx serving the repository root could serve it. Whether that matters depends on how the site is deployed, which is out of scope here.
The 404 page
A deleted profile, a deleted thread and a non-numeric id all answer with notFoundHtml() (routes/seo.js:105-128) rather than an empty shell: text/html, a <title> of Profile not found — Swiftpoint Hub, <meta name="robots" content="noindex">, an inline stylesheet and a single link back to the site root. The copy names the site rather than saying "not found", and the noindex directive keeps deleted URLs out of the index.
What is deliberately absent
There is no hreflang, because the site is one language (inLanguage: "en" is stated in the JSON-LD instead). There is no rel=next/rel=prev pagination markup, because the forum's post windows are an in-page affordance and the only listing that could paginate — the profile gallery — has no pagination at all. There is no AMP, no canonical for the admin panel, and no verification <meta> tag: the Google verification file is a file, not a tag.
Open Graph and social cards
Every card is a real 1200×630 PNG rendered server-side with @napi-rs/canvas. Not an SVG: the SVG that used to sit at /og-image.svg is refused or arbitrarily scaled by several crawlers, and a share preview with no image is worse than a plain link.
A card also has to say something. A logo on a background tells a reader what they are looking at and nothing else, so each card shows the site's own feature summary as a picture.
lib/og-card.js
The primitives and the four card types. Every variant goes through one route, /og-image/:variant.png, because a card variant is a name and every page that wants one points at that rather than growing its own PNG endpoint.
| Function | Served at | Kicker | Title | Content |
|---|---|---|---|---|
homeCard() |
/og-image.png |
SWIFTPOINT//HUB |
Profiles people actually play on |
Counts read at request time (profiles, games, mappings, members) plus a Browse block and a Build block: the four tools and what each one does |
galleryCard() |
/og-image/gallery.png |
SWIFTPOINT//HUB · PROFILES |
Profiles people actually play on |
The gallery's own list: the 8 newest profiles with game chip, title, binding count and download count |
userCard(id) |
/og-image/user-<id>.png |
SWIFTPOINT//HUB |
The member's username | Profiles, comments, downloads and net vote score in the sub-head, then up to 6 of that member's profiles; one "No profiles yet" row when there are none. The bio is the footer |
toolCard(name) |
/og-image/tool-<name>.png |
per tool | per tool | Two content blocks of specimen rows — the tool's own output rendered as a picture |
The four tools and their cards:
| Name | Kicker | Title | Blocks |
|---|---|---|---|
editor |
SWIFTPOINT//HUB · PROFILE BUILDER |
Build a profile in the browser |
A binding, start to finish and And the rest of the mouse |
check |
SWIFTPOINT//HUB · .SPCF CHECKER |
Read a .spcf before you trust it |
What the file does and The verdict |
oled |
SWIFTPOINT//HUB · OLED EDITOR |
Draw on the mouse itself |
What you can draw, plus a real 64×32 OLED specimen at scale 6 |
records |
SWIFTPOINT//HUB · RECORDS |
Record a guide in the real Builder |
Recording and Publishing |
The oled card's specimen is a genuine 64×32 one-bit frame drawn pixel by pixel with the same 5×7 glyph font the OLED editor uses — the card shows the thing the tool makes rather than describing it. An unknown tool name is not invented: toolCard() returns null and the route falls back to the home card, which is a guard rather than a path anyone takes, because the URL is generated from the same list the renderer serves.
The home card is rendered once and cached (homeCache, lib/og-card.js:520-523). A canvas pass per crawler request would be a strange way to spend a CPU. Every other card depends on data, so it is rendered per request and cached by the client for a day (public, max-age=86400). The profile card and the guide cards use an hour (max-age=3600).
lib/docs-og.js
The per-guide card, served at /docs/og/<slug>.png with Content-Type: image/png and Cache-Control: public, max-age=3600 (routes/docs.js:366-372).
Same 1200×630 ground, same palette and same cyan as lib/og-card.js, deliberately not in the guides UI's lime: a share preview that looks like a different site is a different site. The route is registered ahead of the generic guide-image matcher so it wins.
lib/share-image.js
The per-profile tall PNG and the 1200×630 card, served at /profiles/:id/share.png (routes/seo.js:850-861).
It registers SpaceGrotesk.ttf, Inter.ttf and JetBrainsMono.ttf from FONTS_DIR when that variable is set, with a silent fallback to system fonts, and then requires the same UMD modules the browser uses: public/js/share-render.js and public/js/export-helpers.js. That is the whole reason the in-page Share PNG and the served /profiles/:id/share.png are pixel-identical — there is one renderer, called twice.
Composition (public/js/share-render.js):
| Property | Value |
|---|---|
| Width | 1200 px; the tall image's height is computed from its content |
PAD |
52 |
COL_GAP |
36 (tall), OG_ROWS 3 (card) |
FOOTER_H |
96 |
bg0 / bg1 |
#070d16 / #0a1320 |
text |
#e8eefc |
cyan |
#5fd4e8 |
amber |
#f4a25c |
violet |
#a78bfa |
green |
#7ed99a |
| Type | Space Grotesk (brand), Inter (body), JetBrains Mono (values) |
The tall image balances cards into two columns, always into whichever column is currently shorter, and the OG card flows bindings into three. Both end with a footer rule and mecke.dev/swiftpoint · Swiftpoint Z3 Community Profiles; the OG header instead reads SWIFTPOINT Z3 COMMUNITY PROFILES with profile #<id> right-aligned.
test/social-card.test.js is the file that keeps this honest. It asserts that every card is a real 1200×630 PNG, that the four tools each have one and an unknown tool is not invented, that the home card is rendered once and reused, that no page asks a crawler to render an SVG, that the variants share a single route, that every page asks for the large card and declares its size, that each page points at its own card rather than one card for the whole site, that the profile page keeps its own card, that all card families share one palette and it is the profile card's, and that a card is a content card rather than a logo.
Server-rendered content
The pages that must work without JavaScript, and why.
The gallery is drawn by the browser. A crawler and a reader with JavaScript disabled see no profiles on it at all. So the front page carries the same answers as real links, filled in server-side:
| Block | Selector replaced | Filled by |
|---|---|---|
| Community facts | data-stat="profiles|games|mappings|members" inside <dd> |
landingStats() |
| The 6 newest profiles | <ul class="lp-newest" data-newest> |
landingNewest() |
| The published guides (4) | <ul class="lp-guides" data-guides> |
landingGuides() |
| The 3 newest updates | <ul class="lp-newest" data-updates> |
landingUpdates() |
| The 3 open tickets | <ul class="lp-newest" data-tickets> |
landingTickets() |
landingGuides() reads the guides index at request time rather than carrying its own list, so a guide is published by adding a ::: steps block to a page and this list follows without being touched. Descriptions are cut with clipText(), which drops the last partial word before the CSS ellipsis can land after a fragment. A profile whose title or description matches /reupload|migrated from the old swiftpoint forums/i gets an imported badge, because an imported profile is not a new community upload and the card should say which it is rather than letting the date imply otherwise.
The guide pages are entirely server-rendered — routes/docs.js renders the whole document from the markdown, so a reader needs no script at all to read a guide.
The tag filter on the front page is applied when the page is generated, not by the client after load.
test/front-page.test.js asserts that a signed-out reader gets a page and never a gate, that the community numbers are present in the server-rendered markup, that the guides list exists, and that the gallery route is registered ahead of the profile route (because /profiles/:id answers for any single segment, so without that ordering the word "hub" would be read as a profile id). test/forum-render.test.js asserts that the three forum pages carry the shared header and footer in their own markup, not just after a script runs.
The shared chrome is generated by lib/header.js and lib/footer.js, injected into every page by scripts/sync-header.js, and checked in CI by npm run check:header (which pretest runs automatically). A header that only exists after a script loads is a header that a crawler, a reader without JavaScript and a text browser do not have.
The two legal pages are generated from lib/legal.js rather than written as files, so the text cannot drift from the code it describes. Both carry a <base>, a title, a description, a canonical link, the same head every other page carries, and the full header and footer.
Analytics
Only page views are tracked. Nothing else. One table, one insert:
INSERT INTO pageviews (visitor_hash, path) VALUES (?, ?)
The visitor hash is:
HMAC-SHA256(key = SESSION_SECRET || 'swiftpoint-hub-analytics-salt', message = ip)
so the raw IP is never stored and the hash cannot be reversed without the secret. The path is truncated to 255 characters.
trackPageView() is called from routes/seo.js and routes/docs.js. It is skipped for admin sessions on the static pages: /, /oled, /check, /editor, /records-adjacent gates, /forum, /forum/t/:id and /updates all check isAdminSession(req) first, so an admin browsing the site does not inflate the numbers. On /profiles/:id the admin session is excluded from the views counter as well, in the same if. Two routes are not gated: /profiles/hub and /users/:id track unconditionally.
trafficOverview() returns four windows, each { views, unique } where unique is COUNT(DISTINCT visitor_hash):
| Key | Window | Panel label |
|---|---|---|
h1 |
60 minutes | Visits last 60 min |
h24 |
24 hours | Visits last 24 hours |
d7 |
7 days | Visits last 7 days |
d30 |
30 days | Visits last 30 days |
It is surfaced in the admin panel as stats.traffic inside GET /api/admin/overview.
Retention: rows older than 30 days are pruned on every 200th insert, so old data cannot grow without bound and the prune costs nothing measurable.
State plainly what this means: there is no third-party analytics, no cookie-based tracking and no cross-site identifier. The one identifier the site holds is a keyed hash of an IP that exists so "how many people" can be answered without storing who. It is also why the salt falls back to the literal swiftpoint-hub-analytics-salt when SESSION_SECRET is unset: the hash is still one-way and still unlinkable without the key, but a deployment that has not set the secret should treat that as a deployment that has not finished configuring itself. See Security and privacy and Configuration and deployment.
The 200-insert prune is a module-level counter (PRUNE_EVERY, insertCounter), not a timestamp, so it resets on restart. A process that serves fewer than 200 views keeps a little more than 30 days of rows; a busy one keeps very close to 30.
How this is tested
| File | What it pins down |
|---|---|
test/robots.test.js |
The hub serves plain text, not the API 404; the policy matches the host root policy; public content is not blocked; no dead directive is left behind |
test/profile-seo.test.js |
A profile page and a user page each end up with exactly one description and it is their own; the homepage keeps its own; every injecting route drops the static copy first; a profile page is indexable, canonical and named after itself; the homepage describes its gallery without duplicating the page; the gallery is a list of links; every public profile is in the sitemap |
test/social-card.test.js |
Every card is a real 1200×630 PNG; the four tools each have one and an unknown one is not invented; the home card is rendered once and reused; no page asks a crawler to render an SVG; each page points at its own card; one palette across families |
test/front-page.test.js |
A signed-out reader gets a page, never a gate; the community facts are server-rendered; the gallery is registered ahead of the profile route; both pages are in the sitemap |
test/forum-pages.test.js |
/forum and /updates are in the sitemap and the pages are indexable; the pages carry the shared header and footer; a page in the sitemap says its index directive once |
test/header.test.js |
The shared header contract |
test/footer.test.js |
The shared footer contract |
test/legal-pages.test.js |
/privacy and /imprint are generated from lib/legal.js and carry a title, a description and a canonical link |
See also Pages and routes, Markdown reference, Guides system, API reference.