From a896c22751ff2d132484e0739526f01d1d66b1a7 Mon Sep 17 00:00:00 2001 From: nicodes Date: Fri, 21 Aug 2026 11:29:06 -0600 Subject: [PATCH 1/2] Serve the site at www.termca.de and gate every host claim The marketing domain is termca.de; Vercel redirects the apex 308 to www in the project settings, so the canonical technical URL is https://www.termca.de/. The build still named termcade.com in the canonical link, og:url, SoftwareApplication JSON-LD, robots.txt and sitemap.xml. The host is now set once as `site` in astro.config.mjs and the layout derives canonical, og:url and the JSON-LD url from Astro.site instead of a duplicated literal. robots.txt and sitemap.xml are public/ files copied verbatim, so they carry the host literally. tools/check_domain.py gates the built output: every generated host claim must be https://www.termca.de and no termcade.com may survive in dist/. It is proven to fail: restoring the old host in public/robots.txt makes it exit 1 with the stale claim named. app.termca.de and api.termca.de claims are unchanged. Refs aviorstudio/termcade-be#36 --- .github/actions/test/action.yml | 8 +++ README.md | 16 ++++- astro.config.mjs | 10 ++- public/robots.txt | 2 +- public/sitemap.xml | 2 +- src/layouts/Full.astro | 8 ++- tools/check_domain.py | 114 ++++++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 tools/check_domain.py diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 206bbf3..33dde7d 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -37,6 +37,14 @@ runs: - name: No executable scripts in the markup shell: bash run: python3 tools/check_scripts.py dist + # The host the site claims to be is a property of the build output, not of + # any one source file: the layout derives canonical/og:url/JSON-LD from + # `site` in astro.config.mjs while robots.txt and sitemap.xml are copied + # verbatim from public/. The gate reads dist/ and fails on any claim that + # is not https://www.termca.de, and on the old host appearing anywhere. + - name: Every host claim is www.termca.de + shell: bash + run: python3 tools/check_domain.py dist # A page that builds can still be broken in a browser. This loads the # built site in headless Chrome -- preinstalled on the runner, so there is # no browser download -- and fails on console errors, uncaught exceptions, diff --git a/README.md b/README.md index 67f4188..57f58a9 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,16 @@ line up. ## Domain -`src/layouts/Full.astro`, `public/robots.txt` and `public/sitemap.xml` assume -`https://termcade.com`. If the site lands somewhere else, those three files are -the only places the host appears. +The marketing domain is `termca.de`, deployed on Vercel. The apex +`https://termca.de` redirects with HTTP 308 to `https://www.termca.de/` — a +redirect configured in the Vercel project's Domains settings, not in this +repository — so the canonical technical URL is `https://www.termca.de/`. The +application and registry are separate hosts (`app.termca.de`, `api.termca.de`) +and are not touched by anything here. + +The host is set once, as `site` in `astro.config.mjs`; the canonical link, +`og:url` and the SoftwareApplication JSON-LD in `src/layouts/Full.astro` all +derive from `Astro.site`. `public/robots.txt` and `public/sitemap.xml` are +copied as-is, so they carry the host literally. `tools/check_domain.py` checks +every one of those claims in the built output — and that no `termcade.com` +survives anywhere in `dist/` — and runs in CI. diff --git a/astro.config.mjs b/astro.config.mjs index 312a38d..a79355a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,5 +4,13 @@ import { defineConfig } from 'astro/config'; // No integrations. The site is one static page that ships no JavaScript, so // there is no framework runtime to add and nothing to hydrate. The arcade and // its registry are the application; this is only the front door. +// +// `site` is the one place the host is configured. The apex termca.de redirects +// 308 to www in the Vercel project settings, so the canonical URL is www. +// Everything on the page that names the host — canonical link, og:url, and +// the SoftwareApplication JSON-LD — derives from Astro.site; robots.txt and +// sitemap.xml live in public/, are copied as-is, and carry the host literally. // https://astro.build/config -export default defineConfig({}); +export default defineConfig({ + site: 'https://www.termca.de', +}); diff --git a/public/robots.txt b/public/robots.txt index ab45a49..3218be8 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,4 @@ User-agent: * Allow: / -Sitemap: https://termcade.com/sitemap.xml +Sitemap: https://www.termca.de/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml index 355b9a8..edee5c0 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -4,6 +4,6 @@ moves on every unrelated deploy is a worse signal than none. --> - https://termcade.com/ + https://www.termca.de/ diff --git a/src/layouts/Full.astro b/src/layouts/Full.astro index 2ec2bb2..2aca8b7 100644 --- a/src/layouts/Full.astro +++ b/src/layouts/Full.astro @@ -12,7 +12,11 @@ const { 'A terminal arcade in Go. Classic games drawn at sub-cell resolution with block pixels, shipped with Asteroid and Tetris, running every game as a sandboxed WebAssembly package — and the same binary is the dev kit for writing your own.', } = Astro.props; -const canonical = new URL(Astro.url.pathname, 'https://termcade.com'); +// The host appears once, as `site` in astro.config.mjs; everything here +// derives from it. Astro.site is that URL parsed, so a config change is the +// only change a move ever needs. +const site = Astro.site ?? new URL('https://www.termca.de'); +const canonical = new URL(Astro.url.pathname, site); // Two entries rather than one. The SoftwareApplication is the thing people are // being sent to install; the FAQPage is the questions this page actually @@ -24,7 +28,7 @@ const structuredData = [ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'termcade', - url: 'https://termcade.com', + url: site.origin, applicationCategory: 'GameApplication', operatingSystem: 'Linux, macOS, Windows', description, diff --git a/tools/check_domain.py b/tools/check_domain.py new file mode 100644 index 0000000..92a5585 --- /dev/null +++ b/tools/check_domain.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Fail if the built site names any host other than https://www.termca.de. + +The marketing domain is termca.de: the apex redirects 308 to www in the Vercel +project settings, so every host claim the build emits must be www.termca.de. +The claims are spread across three files with two mechanisms — the layout +derives canonical/og:url/JSON-LD from `site` in astro.config.mjs, while +robots.txt and sitemap.xml are public/ files copied verbatim — so no single +source file is a complete picture. This gate reads the built output, which is +the only place all of them land together. + +Checks, on dist/: + +- index.html: exactly one and one og:url, both + https://www.termca.de/; the SoftwareApplication entry in the JSON-LD has + url https://www.termca.de +- robots.txt: the Sitemap line names https://www.termca.de/sitemap.xml +- sitemap.xml: every names https://www.termca.de/ +- no file anywhere in dist/ contains the old host termcade.com + + python3 tools/check_domain.py dist +""" +import json +import pathlib +import sys +from html.parser import HTMLParser + +HOST = 'https://www.termca.de' +OLD_HOST = 'termcade.com' + + +class Head(HTMLParser): + def __init__(self): + super().__init__() + self.canonical = [] + self.og_url = [] + self.ld = [] + self._in_ld = False + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == 'link' and attrs.get('rel') == 'canonical': + self.canonical.append(attrs.get('href', '')) + elif tag == 'meta' and attrs.get('property') == 'og:url': + self.og_url.append(attrs.get('content', '')) + elif tag == 'script' and attrs.get('type', '').lower() == 'application/ld+json': + self._in_ld = True + + def handle_endtag(self, tag): + if tag == 'script': + self._in_ld = False + + def handle_data(self, data): + if self._in_ld: + self.ld.append(data) + + +def software_application_urls(head): + urls = [] + for blob in head.ld: + data = json.loads(blob) + entries = data if isinstance(data, list) else [data] + for entry in entries: + if isinstance(entry, dict) and entry.get('@type') == 'SoftwareApplication': + urls.append(entry.get('url', '')) + return urls + + +def main(root): + bad = [] + root = pathlib.Path(root) + index = root / 'index.html' + if not index.is_file(): + sys.exit(f'{root}: no index.html — was the site built?') + + head = Head() + head.feed(index.read_text()) + + for what, got, want in [ + ('', head.canonical, [f'{HOST}/']), + ('og:url', head.og_url, [f'{HOST}/']), + ('SoftwareApplication JSON-LD url', software_application_urls(head), [HOST]), + ]: + if got != want: + bad.append(f'{index}: {what} is {got or [""]}, expected {want}') + + for name, want in [ + ('robots.txt', f'Sitemap: {HOST}/sitemap.xml'), + ('sitemap.xml', f'{HOST}/'), + ]: + path = root / name + if not path.is_file(): + bad.append(f'{path}: missing') + elif want not in path.read_text(): + bad.append(f'{path}: no {want!r}') + + for path in sorted(root.rglob('*')): + if path.is_file() and OLD_HOST in path.read_bytes().decode('utf-8', 'replace'): + bad.append(f'{path}: still names the old host {OLD_HOST}') + + if bad: + print(f'{root}: host claims are wrong — the canonical host is {HOST}:', + file=sys.stderr) + for line in bad: + print(f' {line}', file=sys.stderr) + sys.exit(1) + print(f'{root}: canonical, og:url and JSON-LD name {HOST}; robots and ' + f'sitemap point at it; no {OLD_HOST} anywhere', file=sys.stderr) + + +if __name__ == '__main__': + if len(sys.argv) != 2: + sys.exit('usage: check_domain.py dist') + main(sys.argv[1]) From 9a071bcfa31d64e5ee6786010ae8ec5c5a5a9b64 Mon Sep 17 00:00:00 2001 From: nicodes Date: Fri, 21 Aug 2026 14:10:59 -0600 Subject: [PATCH 2/2] Validate every sitemap and robots host claim, not just the expected one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 1: the domain gate searched for the canonical Sitemap line and value as substrings, so a build that kept the right claim and added a foreign one beside it still passed — contradicting the gate's "every host claim" contract and risking wrong crawler metadata. robots.txt is now parsed for all Sitemap: directives and sitemap.xml for all values (as XML, so a malformed sitemap fails too), and each must equal exactly the canonical https://www.termca.de claim. Both failure paths are proven deliberately: an extra https://other.example/ and an extra `Sitemap: https://other.example/sitemap.xml` each make the gate exit 1 naming the foreign value; the fixtures were then restored and the full suite re-run green. Refs aviorstudio/termcade-be#36 --- tools/check_domain.py | 54 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/tools/check_domain.py b/tools/check_domain.py index 92a5585..e15e614 100644 --- a/tools/check_domain.py +++ b/tools/check_domain.py @@ -14,19 +14,27 @@ - index.html: exactly one and one og:url, both https://www.termca.de/; the SoftwareApplication entry in the JSON-LD has url https://www.termca.de -- robots.txt: the Sitemap line names https://www.termca.de/sitemap.xml -- sitemap.xml: every names https://www.termca.de/ +- robots.txt: every Sitemap: value — all of them parsed, not just the first + — is https://www.termca.de/sitemap.xml +- sitemap.xml: every value — the file is parsed as XML, not grepped — + is https://www.termca.de/ - no file anywhere in dist/ contains the old host termcade.com +The sitemap and robots checks validate every value rather than searching for +the right one: a build that kept the canonical claim but added a second, +foreign one must still fail. + python3 tools/check_domain.py dist """ import json import pathlib import sys +import xml.etree.ElementTree as ET from html.parser import HTMLParser HOST = 'https://www.termca.de' OLD_HOST = 'termcade.com' +SITEMAP_NS = '{http://www.sitemaps.org/schemas/sitemap/0.9}' class Head(HTMLParser): @@ -66,6 +74,33 @@ def software_application_urls(head): return urls +def robots_sitemaps(path): + """Every Sitemap: directive in robots.txt. A missing file is one failure, + not a silently empty list.""" + if not path.is_file(): + return None + return [ + value.strip() + for line in path.read_text().splitlines() + for directive, _, value in [line.partition(':')] + if directive.strip().lower() == 'sitemap' + ] + + +def sitemap_locs(path): + """Every in the sitemap, parsed as XML. Returns None for a missing + file; malformed XML exits — a sitemap that is not XML is broken however + it names the host.""" + if not path.is_file(): + return None + try: + root = ET.parse(path).getroot() + except ET.ParseError as error: + sys.exit(f'{path}: not valid XML ({error}) — a broken sitemap is a ' + f'broken host claim') + return [(loc.text or '').strip() for loc in root.iter(f'{SITEMAP_NS}loc')] + + def main(root): bad = [] root = pathlib.Path(root) @@ -84,15 +119,18 @@ def main(root): if got != want: bad.append(f'{index}: {what} is {got or [""]}, expected {want}') - for name, want in [ - ('robots.txt', f'Sitemap: {HOST}/sitemap.xml'), - ('sitemap.xml', f'{HOST}/'), + for name, claim, got, want in [ + ('robots.txt', 'Sitemap:', robots_sitemaps(root / 'robots.txt'), + [f'{HOST}/sitemap.xml']), + ('sitemap.xml', '', sitemap_locs(root / 'sitemap.xml'), + [f'{HOST}/']), ]: path = root / name - if not path.is_file(): + if got is None: bad.append(f'{path}: missing') - elif want not in path.read_text(): - bad.append(f'{path}: no {want!r}') + elif got != want: + bad.append(f'{path}: {claim} values are {got or [""]}, ' + f'expected exactly {want}') for path in sorted(root.rglob('*')): if path.is_file() and OLD_HOST in path.read_bytes().decode('utf-8', 'replace'):