Marketing + utility site for the Swift Secured casino trust-seal certification service. Built with Astro (static output) and Tailwind CSS v4.
- Astro — static site generation, zero client JS by default
- Tailwind CSS v4 — CSS-first theme config (
src/styles/global.css), oklch design tokens - @fontsource-variable/inter — self-hosted variable Inter font (no external Google Fonts request)
- @astrojs/sitemap — auto-generated sitemap for SEO
src/
components/ Header, Footer, LanguageSwitcher, Seal, CasinoCard, PlanCard, ...
data/ casinos.ts, languages.ts — locale table incl. RTL flags
i18n/
types.ts the Translation contract every locale must satisfy
index.ts useTranslations(), localePath(), localeStaticPaths()
locales/ en.ts is the source of truth; 18 siblings mirror it
layouts/ Layout.astro — SEO meta, hreflang, header/footer shell
lib/url.ts withBase() — prefixes internal links for GitHub Pages
pages/[...locale]/ one file per route; the rest param is empty for English
styles/ global.css — Tailwind + design tokens
Copy lives in src/i18n/locales/, not in the pages. All 19 files satisfy the same
Translation interface, so a missing key is a type error rather than undefined
rendered into a page nobody on the team can proofread.
Routing: English is served at the root and every other locale takes a /<code>/
prefix — hence [...locale] (rest, can be empty) rather than [locale]. Adding a
language means adding a row to src/data/languages.ts, a file in locales/, and an
entry in the astro.config.mjs locales array.
Interactive bits (language switcher, FAQ accordion, casino search, verify lookup, apply
form) are plain vanilla <script> tags scoped per component/page — no JS framework.
| Command | Action |
|---|---|
npm install |
Install dependencies |
npm run dev |
Start local dev server at localhost:4321 |
npm run build |
Build the production site to ./dist/ |
npm run preview |
Preview the production build locally |
npx astro check |
Type-check .astro files |
Two independent targets:
| Target | Command | URL | Use for |
|---|---|---|---|
| GitHub Pages | git push |
https://oliverd25.github.io/swift-secured/ | The shareable link. Always up. |
| Home server | ./deploy.sh |
https://swiftsecure.serveousercontent.com/ | Previewing uncommitted work. |
Run ./publish.sh instead of ./deploy.sh when the session is already on
the home server (phone-driven work). Same target, but it builds in place rather
than copying 4,400 files across the network, so it does not need this PC awake.
The full toolchain runs on rde@192.168.88.166 at ~/projects/swift-secured —
Node 22, Python 3.12, Playwright with Chromium already installed. Everything
below works there unchanged; paths resolve from each script's own location
rather than a drive letter, so nothing needs editing per machine.
npm run build && npx astro check # site
python3 research/scripts/test_drafts.py # 1,275 assertions on the outreach drafts
python3 research/scripts/test_directory_seo.py # indexing + consent, reads dist/
python3 research/scripts/test_report_tooling.py # signature, freshness, .partial guard
node scripts/test-apply-form.mjs # apply form in a real browser, own dev server
python3 scripts/test-site-claims.py # what the built site claims, all locales
node crawler/seal-census.mjs # ~15 min, rewrites research/seal-census.json
./publish.sh # build and swap into the live docrootThe operator reports are a two-step build, and the order is enforced.
15-verify-wave.py re-requests every finding and writes both a human-readable
outreach-wave-verified.md and a machine-readable wave-verification.json.
18-generate-reports.py refuses to run without the JSON, because it may only
print a "check this yourself" curl line for a URL curl was proved to reproduce.
Passing a row limit to the verifier writes .partial files and leaves the real
ones alone.
python3 research/scripts/15-verify-wave.py # ~4 min over the wave, hits live sites
python3 research/scripts/18-generate-reports.pyWho signs the letters comes from research/sender.json; --sender and
--reply still override it for a one-off run. Run both on the day you send.
Each letter prints the generation date and states every line was re-checked
"on the day this was sent" — the generator warns when the measurements are
older than that claim, reading the date from inside wave-verification.json
rather than the file's mtime, which a git clone resets.
--outdir, --verified and --sender-file exist so test_report_tooling.py
can run against fixtures without writing over the real letters.
Scan results depend on which machine you run them from. 95 of 489 sites refuse the homelab outright, and the workstation is refused by a different set. Numbers move between hosts because our network position moved, not because the market did — so don't compare a homelab census against a workstation one and read the difference as a trend.
Long sweeps need detaching properly, or they die with the SSH session:
setsid nohup node crawler/seal-census.mjs > research/seal-census.log 2>&1 < /dev/null &git push triggers .github/workflows/deploy.yml, which builds with
SITE/BASE_PATH set so links resolve under the /swift-secured/
sub-path Pages serves from. Local builds and ./deploy.sh leave those unset
and build at the root — see src/lib/url.ts.
The home-server preview depends on a serveo tunnel that does not survive a
reboot. ./tunnel-status.sh --restart brings it back.
The two targets can silently disagree. ./deploy.sh publishes the working
directory; git push publishes committed code. Deploy uncommitted work and forget
to commit, and the preview shows something the public link doesn't, with nothing on
screen to say so. git status before sending anyone a link.
This deploy setup is generalised in the global static-site-deploy skill, which
covers the base-path, CI and indexing traps in more depth.
-
Domain: done.swiftsecured.comis live and served by the Cloudflare Worker, not by GitHub Pages, so there is noCNAMEfile and nothing to set under Settings → Pages.astro.config.mjsfalls back to the domain andsrc/pages/robots.txt.tsbuilds the sitemap URL from the same value — the staticpublic/robots.txtthis list used to name no longer exists, so the two cannot drift apart. -
Mail hosting: done 6 August 2026.dmytro@swiftsecured.comis a real mailbox on Zoho Mail Lite, EU data centre, and sending and receiving are both verified. MX ismx/mx2/mx3.zoho.eu(10/20/50), SPF isv=spf1 include:zohomail.eu ~all, and DKIM signs with selectorzmail. A test to Gmail landed in the inbox withSPF: PASSandDKIM: PASS, both aligned to the domain. The free tier was not usable: it is webmail-only and, more decisively, not offered in the EU data centre at all.Mail Lite does have IMAP and SMTP. An earlier version of this line said it did not. That was wrong and was never checked — it came from reading the free plan's "IMAP/POP/Active Sync not included" and assuming it applied one tier up. Zoho's IMAP and POP help pages exclude only the Free plan, and
smtppro.zoho.eu:587answers with250-AUTH LOGIN PLAINfrom here. Usesmtppro.zoho.eu/imappro.zoho.eu(paid-tier hosts, notsmtp.zoho.eu). Port 465 is filtered on this network; 587 with STARTTLS works. IMAP may still need enabling per user under Settings → Mail Accounts → IMAP Access — that is an account toggle, not a plan limit.Having SMTP does not change how the wave should be sent. Twenty-one personalised letters still go out by hand, because the reason was never a missing protocol — it is that hand-sent mail from a real mailbox lands in inboxes and bulk-relayed cold mail does not.
-
DMARC: done 6 August 2026._dmarcTXT isv=DMARC1; p=none; rua=mailto:dmytro@swiftsecured.com, and Gmail now reportsDMARC: PASS (p=NONE sp=NONE dis=NONE)alongside SPF and DKIM. It deliberately stays atp=none, which monitors without blocking. Tighten toquarantineand laterrejectonly after weeks ofruareports show mail passing consistently — a strict policy published before the setup is proven rejects our own outreach. Nobody has yet confirmed theruaaggregate reports actually arrive; they are XML and land at the address above. -
Apply form — contact address: done 6 August 2026.PUBLIC_CONTACT_EMAILis set on the production Worker and live on all 19 apply pages, so an operator always has a route that works. Variables live in the Cloudflare dashboard → theswift-secureWorker → Settings → Variables and Secrets, under the build configuration — see the table in.cloudflare-deploy.md. The GitHub Actions repository variables are not the same thing and will not fix production: since the move to Cloudflare,.github/workflows/deploy.ymlbuilds only the noindexed Pages mirror..envis gitignored, so it does not travel with the repo — copy.env.exampleon any new machine. -
Apply form — the form itself still cannot submit.
PUBLIC_FORM_ENDPOINTis unset, so/apply/shows the fallback address and the operator has to open their own mail client. The code is ready for either service and the choice is a variable, not a code change: Formspree identifies the form from the endpoint URL and wantsPUBLIC_FORM_KEYleft empty; Web3Forms posts everyone to one shared endpoint and needsPUBLIC_FORM_KEYset, which the form appends asaccess_key. Set the values in.envfirst, runnode scripts/test-apply-form.mjs, then add them to both Workers. The form never reports a success it did not get — a non-2xx keeps the form on screen with the typed values intact, which is covered by 21 browser checks in that test. -
Verify lookup: done 4 August 2026./verify/now matches exactly against seals built fromsrc/data/casinos.tsat build time. No badge has been issued, so every lookup returns not-found and the copy says why. A casino gains asealIdwhen its badge goes live and the page answers with no further change. -
Casino directory:
casinos.tsis a static in-memory list — replace with a real data source when there's one. -
Flag images: the language switcher hotlinks flags from
flagcdn.com— confirm that's acceptable for production or self-host them. -
OG image: no
og:imageis set yet — add one for richer social link previews.