mendelson/website

★ 0Forks 0HTMLGitHub ↗Compare

README

mmendelson.com

Personal website of Mateus Mendelson — a dependency-free static site that replaces the previous WordPress installation. No database, no PHP, no hosting bills: just HTML/CSS/JS generated by a small Python script and served by any static host (GitHub Pages, Cloudflare Pages, Netlify, …).

Project layout

content/          Page bodies (HTML fragments), one file per page
templates/        base.html — the shared page shell (brand bar, footer)
assets/
  css/style.css   All styling
  js/site.js      Teaching-accordion deep-linking + the language switch
  images/         Pictures, icons, favicon
  files/          PDFs (CV, music sheets, …)
build.py          The generator: content + template -> public/
tools/
  fetch_assets.sh Downloads original media from the old WP site
  check_build.py  Builds, then asserts the output is complete and substituted
  analytics-family-check/
                  Cross-repo browser check: is a hub -> apps -> run visit
                  really ONE measured journey? (needs all three repos)
public/           Generated output (git-ignored; rebuilt on every deploy)

Build locally

python3 tools/check_build.py   # builds into ./public, then checks the output
python3 build.py               # just build, no checks
# preview:
cd public && python3 -m http.server 8000   # then open http://localhost:8000

No third-party packages are required (standard library only).

check_build.py is the gate the deploy workflow runs, so a local run and CI are the same command. It fails on a leftover {{PLACEHOLDER}}, a page / redirect / short link the registry claims and the output lacks, two registries claiming one URL, and a tracked short link that has lost its measurement — that last one is the failure nobody would notice, because the link keeps redirecting perfectly while reporting nothing. Each check prints a count and a count of zero fails.

Editing content

  • Text of a page → edit the matching file in content/.
  • Navigation, page list, redirects → edit the tables near the top of build.py (PAGES, REDIRECTS, TRACKED_SHORT_LINKS).
  • Look & feel → assets/css/style.css.

Re-run python3 build.py after any change.

Language support

The site supports five languages — German, English, Spanish, French, Portuguese — matching the set already used on apps.mmendelson.com and run.mmendelson.com, but via a different mechanism: one URL per page, no /de/ /en/ /es/ /fr/ /pt/ folders. Every translatable string ships all five languages inline as <span class="t"><span lang="en">…</span><span lang="de">…</span> <span lang="es">…</span><span lang="fr">…</span><span lang="pt">…</span></span>; CSS (html:lang(xx) .t > [lang="xx"]{display:inline} in style.css) shows only the one matching <html lang>. This keeps a single canonical URL per page (no hreflang/sitemap fan-out, no duplicate content across five folders) at the cost of not having language-specific URLs to share/index — right tradeoff for a low-traffic personal hub, wrong one for apps/run's larger content volume, which is why those two keep their existing per-language folders.

  • Detection: a <head> script (in templates/base.html) checks localStorage.mm_lang first; if unset, it falls back to navigator.language, matching apps.mmendelson.com's own detection list (['de','en','es','fr','pt'], default en). Runs before first paint, so there's no flash.
  • Manual override: the DE EN ES FR PT control in the brand bar (.lang-switch) lets a visitor pick any of the five regardless of browser language. Click sets document.documentElement.lang and persists the choice to localStorage.mm_lang (assets/js/site.js) — no navigation, no reload.
  • What's translated: UI chrome and descriptive copy (section labels, intros, buttons, breadcrumbs, page titles/subtitles, the small off/ music-sheets/a-coxinha prose pages). Not translated, by design: proper nouns, product/brand names, academic publication titles and venues, teaching discipline names, and file-format "kind" tags (slides/folder/ notebook/…) — translating these would misrepresent them or add churn-prone busywork for no reader benefit. The fga/iesb/projecao teaching-institution pages are also left as a single language (their existing content, inherited from the original site) rather than translated — same reasoning.
  • CV button: only an English and a Portuguese résumé file exist. The button shows the Portuguese one for pt, and falls back to the English one for every other language (de/en/es/fr) — see the CSS comment above .btn-cv in style.css.
  • Adding a sixth language: add a <span lang="xx"> to every .t group (search for the pattern above), add the CSS html:lang(xx) .t > [lang="xx"]{display:inline} rule, add xx to the langs array in the <head> detection script, and add a button to .lang-switch in templates/base.html.

Media assets

The repo ships placeholder images/PDFs so the layout renders immediately. Replace them with the originals:

bash tools/fetch_assets.sh   # from a machine that can reach mmendelson.com
python3 build.py

See ASSETS_NEEDED.md for the full list and source URLs.

Deploying

GitHub Pages (configured)

.github/workflows/deploy.yml builds the site and publishes it on every push to main. Enable it once under Settings → Pages → Build and deployment → Source: GitHub Actions.

The site is served from the project URL https://mendelson.github.io/website/, so every in-site link is prefixed with the base path /website. This is driven by the CUSTOM_DOMAIN setting near the top of build.py:

  • CUSTOM_DOMAIN = "" → builds for mendelson.github.io/website (BASE = "/website", no CNAME).
  • CUSTOM_DOMAIN = "mmendelson.com" → builds for the apex domain (BASE = "", emits CNAME). Set this once the domain's DNS points at GitHub Pages.

Cloudflare Pages / Netlify (alternative)

  • Build command: python3 build.py
  • Output directory: public

URL structure & redirects

URLs and redirects mirror the old WordPress site 1:1. The source of truth is PAGES and REDIRECTS in build.py; the tables below are the human-readable summary. (All in-site paths are served under the base path — /website/… on GitHub Pages, /… on the apex domain.)

Pages (render content)

URL Page
/ Home
/teaching/ Teaching
/teaching/fga/ University of Brasília – Gama
/teaching/university-center-iesb/ IESB
/teaching/projecao/ Projeção
/publications/ Publications
/extra-resources/ Extra resources
/off/ Side projects
/off/music-sheets/ Music Sheets
/off/a-coxinha/ A Coxinha
/cv/ CV

Redirects that land on a page

Destination page Redirecting paths
/ /i/, /inicio/
/teaching/ /t/, /teach/
/teaching/fga/ /f/, /fga/
/teaching/university-center-iesb/ /u/, /uni/, /university/, /university-center-iesb/
/teaching/projecao/ /projecao/
/publications/ /pub/, /publication/
/extra-resources/ /e/, /extra/
/off/ /o/
/off/music-sheets/ /m/, /music/, /music-sheets/
/off/a-coxinha/ /a/, /a-coxinha/

Redirects that go off-site

Destination Redirecting paths
apps.mmendelson.com /garmin-apps/, /garmin/, /g/
run.mmendelson.com /off/run/, /run/, /r/
run.mmendelson.com/gallery /off/running-gallery/
open.spotify.com/show/… (Byte Papo) /off/byte-papo/, /byte-papo/, /b/
taggo.one/mmendelson (contact card) /contact/, /c/, /findme/, /contato/
kiezelpay.com/… (Garmin app pricing) /garmin-pricing/
api.mmendelson.com/pair /pair/
apps.mmendelson.com/tracker (Garmin Tracker Data Field) /tracker/, /track/, /tracker-data/, /tracker-data-field/

The Garmin Tracker Data Field companion moved to apps.mmendelson.com/tracker. The /tracker/ redirects above preserve the query string (?trackId=…) so existing watch-generated links keep working.

Tracked short links

Numbered slugs for print, QR codes, slides and bios. They redirect like everything above, but they are measured first: the source of truth is TRACKED_SHORT_LINKS in build.py.

URL Destination GA4 event
/1 apps.mmendelson.com short_link_click {code: "1", to_site: "apps"}

mmendelson.com/1 works without the trailing slash: GitHub Pages 301s it to /1/. That is not an assumption about Pages — https://mmendelson.com/g and /t answer 301 → /g/ and 301 → /t/ on the live site today, and /1 answers 404 until this ships.

Why these are not just another row in REDIRECTS. A plain redirect stub carries no analytics and bounces immediately, so "how many people scanned that QR code?" has no answer at all. A tracked stub loads GA4 (the family stream, same Consent Mode v2 defaults as every other page), records a page_view for the slug and a short_link_click event carrying the code, and only then navigates. Referrer, country, region, device, browser and timestamp all arrive with the page_view, so the code is the only thing that has to be sent explicitly.

Because the three family sites share one measurement stream, the hop does not end the session: /1 becomes the session's landing page and everything the visitor then does on apps.mmendelson.com is the same session, so the funnel is a Path exploration away. That is also why the stub does not append utm_* to the destination — doing so would start a fresh campaign session at the hop and cut the journey in two.

How it avoids costing the visitor anything. Navigating too early loses the hit, so the stub waits for gtag's event_callback — ceiling 700 ms — with a hard 1200 ms cap for the case where gtag never loads at all (ad blocker, dropped request). Measured in Chromium against a stubbed gtag: 102 ms when the tag loads, 1236 ms when it is blocked, 3042 ms with JavaScript disabled entirely (the <meta refresh> backstop); all three land on apps.mmendelson.com and request it exactly once. The visible link works with no JS at all.

Navigation goes through a click on the real <a> rather than location.replace() directly, so GA4's link handling sees an ordinary click. A 600 ms fallback re-reads the href, so the redirect still happens if the click did nothing. (Session continuity across the hop does not depend on this — it comes from the shared _ga cookie on .mmendelson.com, verified by tools/analytics-family-check/run.sh.)

Codes are permanent. A printed code cannot be re-pointed once it is in the wild without lying about what it measures. Retire a code rather than reuse it, and add new ones by appending.

No consent bar on these stubs, deliberately: the visitor is there for under a second and lands on a site that shows its own. Consent defaults to denied exactly as elsewhere, so an un-consented hit is a cookieless ping, and a visitor who already accepted anywhere in the family is honoured — the stub reads the same .mmendelson.com consent cookie every page reads.

Some short aliases reach an off-site destination through one in-site hop (e.g. /g/ → /garmin-apps/ → apps.mmendelson.com); the tables show the final destination.

Analytics

All three family sites — this hub, apps.mmendelson.com and run.mmendelson.com — send to one GA4 measurement stream (GA_MEASUREMENT_ID in build.py), which is what makes a visit that walks between them a single session instead of three. They are subdomains of one domain, so the _ga cookies land on .mmendelson.com and every site reads the same ones; no cross-domain linker is involved.

Consent is recorded in a cookie on .mmendelson.com too — not localStorage, which is per-origin and therefore made each site ask again and, worse, kept two of the three sending in cookieless mode after the visitor had already accepted on the first. Accepting also grants the Google Signals consents (the age/gender/interest estimates), so the banner names them and links to the family privacy policy; anyone who only answered the earlier banner is asked once more rather than being opted in silently.

The cross-repo plan, the event taxonomy, what GA4 collects by itself versus what the pages send, where each answer lives in the GA4 UI, and the account-side steps that no code can do are all in ANALYTICS_TRACKING.md.

bash tools/analytics-family-check/run.sh   # needs all three repos side by side

Notes / known gaps

  • The Garmin Tracker Data Field companion moved to apps.mmendelson.com/tracker (in the apps-website repo). /tracker/ (and its aliases) now redirect there, carrying the ?trackId=… query so existing links keep working.

Contributors

mendelsonclaude

Issues