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, …).
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)
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:8000No 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.
- 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.
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 (intemplates/base.html) checkslocalStorage.mm_langfirst; if unset, it falls back tonavigator.language, matching apps.mmendelson.com's own detection list (['de','en','es','fr','pt'], defaulten). Runs before first paint, so there's no flash. - Manual override: the
DE EN ES FR PTcontrol in the brand bar (.lang-switch) lets a visitor pick any of the five regardless of browser language. Click setsdocument.documentElement.langand persists the choice tolocalStorage.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-coxinhaprose 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. Thefga/iesb/projecaoteaching-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-cvinstyle.css. - Adding a sixth language: add a
<span lang="xx">to every.tgroup (search for the pattern above), add the CSShtml:lang(xx) .t > [lang="xx"]{display:inline}rule, addxxto thelangsarray in the<head>detection script, and add a button to.lang-switchintemplates/base.html.
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.pySee ASSETS_NEEDED.md for the full list and source URLs.
.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 formendelson.github.io/website(BASE = "/website", noCNAME).CUSTOM_DOMAIN = "mmendelson.com"→ builds for the apex domain (BASE = "", emitsCNAME). Set this once the domain's DNS points at GitHub Pages.
- Build command:
python3 build.py - Output directory:
public
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.)
| 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 |
| 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/ |
| 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.
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.
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- The Garmin Tracker Data Field companion moved to
apps.mmendelson.com/tracker(in theapps-websiterepo)./tracker/(and its aliases) now redirect there, carrying the?trackId=…query so existing links keep working.