This is the source code for brianbondy.com, a personal website built with Go.
- Go 1.19+
- Python 3.7+ (for image processing scripts)
cwebptool (for WebP image conversion)
- Clone the repository
- Install the
cwebptool:# macOS brew install webp # Ubuntu/Debian sudo apt-get install webp # CentOS/RHEL sudo yum install libwebp-tools
go run .The site will be available at http://localhost:8080
make testEnglish is the default language and French is the only additional supported
language. Use the EN/FR control in the top-right corner, or add ?lang=fr
to a local URL while testing. A missing translation always falls back to its
English source string.
UI strings live in locales/en.json and locales/fr.json. After adding or
changing translatable template or JavaScript copy, update the deduplicated
catalogs and then fill any new empty French values:
make localesThe English blog workflow is unchanged: write data/markdown/blog/123.markdown
and add its entry to data/blogPostManifest.json. An optional French body is
stored beside it as data/markdown/blog/123.fr.markdown; translated titles and
descriptions live in data/blogPostTranslations.fr.json. Missing files, posts,
or individual metadata fields fall back to English automatically.
A typo fix does not require regenerating a whole post. Edit the relevant line
in the English file and, when necessary, the same line in its French sidecar.
Hand-written sidecars are never overwritten by the optional generator unless
--force is explicitly passed. To generate only missing French sidecars with
a locally installed Ollama model:
make blog-translations
# or: python3 scripts/translate_blog_posts.py --post 190make formatTo fetch the latest cheatsheets markdown from https://github.com/bbondy/cheatsheets and generate the local manifest:
make cheatsheetsThis writes data/cheatsheetsManifest.json and data/markdown/cheatsheets/*.md.
make deploy- Create a new markdown file in
data/markdown/blog/with the next available ID - Add the blog post metadata to
data/blogPostManifest.json - Add images to
static/img/blogpost_[ID]/directory - Process the images for WebP optimization:
Or process all blog post images:
make blog-images [ID]
make blog-images
- Test locally:
go run . - Run tests:
make test - Deploy:
make deploy
The website automatically optimizes images for better performance by:
- Converting images to WebP format
- Adding lazy loading
- Adding async decoding
- Providing responsive image support
Convert all images to WebP:
make webpForce convert all images (even if WebP already exists):
make webp-forceProcess images for a specific blog post:
python3 scripts/process_new_blog_images.py [blog_post_id]scripts/convert_images_to_webp.py- Main WebP conversion scriptscripts/process_new_blog_images.py- Blog post specific image processingscripts/download_strava_images.py- Download images from Strava activitiesscripts/generate_books.py- Generate book data from Goodreads export
data/- Blog posts, projects, and other contentstatic/- CSS, images, and other static assetstemplates/- HTML templatesscripts/- Utility scripts for content managementhandlers.go- HTTP request handlersroutes.go- URL routingutils.go- Utility functions including image optimization
- Go 1.18+
- Python 3 (for scripts)
- golangci-lint (
brew install golangci-lint) - Google Cloud SDK (for deployment)
- Create a new markdown file in
data/markdown/blog/. - Add an entry to
data/blogPostManifest.jsonwith the new post's metadata. - (Optional) Add images to
static/img/blogpost_<id>/.
To check for linting issues without fixing them:
make lint
To automatically format and fix linting issues:
make format
To run all tests:
make test
Authenticate with Google Cloud (if you haven't already):
make auth
Then deploy:
make deploy
Download an export from https://www.goodreads.com/review/import and save it to data/goodreads_library_export.csv.
Run python3 scripts/generate_books.py
- Facebook page and related groups (Adjust visibility to Public)
- Strava (if about running)
See LICENSE.
The script scripts/fetch_memorable_runs.py helps automate the process of adding elapsed time (hours and minutes) to each running activity in data/memorableRuns.json.
Features:
- Extracts time from the description if present (and cleans up duplicates)
- If time is missing, fetches the elapsed time from the public Strava activity web page (no API credentials required)
- Updates the manifest with a
"time"field for each activity - Cleans up the description to avoid duplicate time display
Requirements:
- Python 3
requestsandbeautifulsoup4libraries (install withpip install requests beautifulsoup4)
Usage:
python3 scripts/fetch_memorable_runs.pyAfter running, your data/memorableRuns.json will be updated with time fields for each activity. Activities without a Strava activity URL or with non-standard pages will be flagged for manual review.
The contribution graph on the running page is generated from data/stravaRunManifest.json.
Requirements:
- Strava API access (either
STRAVA_ACCESS_TOKEN, orSTRAVA_CLIENT_ID+STRAVA_CLIENT_SECRET+STRAVA_REFRESH_TOKEN)
Usage:
make strava-run-manifestIf the token is missing or expired, set one of the following before running:
STRAVA_ACCESS_TOKEN=... make strava-run-manifestOr use refresh-token credentials (no browser login):
STRAVA_CLIENT_ID=... STRAVA_CLIENT_SECRET=... STRAVA_REFRESH_TOKEN=... make strava-run-manifestOr use OAuth client credentials (opens a browser for login when refresh token is not provided):
STRAVA_CLIENT_ID=... STRAVA_CLIENT_SECRET=... make strava-run-manifestThe script scripts/fetch_github_stats.py automates fetching commit and pull request counts for your projects from GitHub and updates data/projectManifest.json.
Features:
- Reads commit and PR counts by author (bbondy) from the GitHub REST search API when
GITHUB_TOKEN(orGH_TOKEN) is set, and falls back to scraping the public search pages otherwise - Supports keyword-based filtering for subprojects (see
searchKeywordsin the manifest) - Handles abbreviated numbers (e.g., "2.3k" → 2300)
- Retries on rate limiting with exponential backoff
- Waits 2 seconds between all requests to avoid rate limits
- Only includes real fetched data (removes stats if not available)
Requirements:
- Python 3
requestslibrary (install withpip install requests)
Usage:
python3 scripts/fetch_github_stats.pyAfter running, your data/projectManifest.json will be updated with the latest commit and PR counts for each project. If a project's data can't be fetched (e.g., due to rate limiting), it will be omitted from the stats until a successful fetch. If no project at all yields stats, the script exits non-zero, since that means the search backend rejected us rather than the projects being genuinely empty.
Anonymous scraping is heavily rate limited from shared addresses such as CI runners, so pass a token when running unattended:
GITHUB_TOKEN=$(gh auth token) make github-stats.github/workflows/update-stats.yml refreshes the GitHub and Strava manifests every day at 09:17 UTC, commits any changes back to master, and deploys the result to App Engine. It can also be run on demand from the Actions tab, where targets limits the run to github or strava, dry_run fetches and prints the diff without committing or deploying, and force_deploy deploys even when nothing changed.
The push uses a repository deploy key rather than GITHUB_TOKEN so that the resulting commit triggers CI.
The manifests under data/ are read from disk on every request rather than compiled into the binary, so a commit alone does not change the live site. The workflow therefore runs gcloud app deploy whenever it commits something. It does not run make deploy, since the full all pipeline would also reformat code and bump the CSS cache version, none of which is warranted by a data-only change.
App Engine standard caps a project at 210 versions, which a daily deploy would eventually exhaust, so the workflow prunes afterward. It keeps the 10 most recent versions and deletes older ones, always skipping whichever version is serving traffic.
Repository secrets:
| Secret | Required | Purpose |
|---|---|---|
STATS_DEPLOY_KEY |
yes | Private half of a write-enabled deploy key on this repo, used to push the daily commit |
STRAVA_CLIENT_ID |
yes | Strava API application ID |
STRAVA_CLIENT_SECRET |
yes | Strava API application secret |
STRAVA_REFRESH_TOKEN |
yes | Long-lived Strava refresh token, minted by make strava-refresh-token |
STATS_GITHUB_TOKEN |
no | PAT used for the search API instead of the built-in GITHUB_TOKEN |
Google Cloud needs no secret. The workflow federates into the go-brianbondy project with Workload Identity, so nothing long lived is stored in GitHub:
| Resource | Value |
|---|---|
| Provider | projects/77808394846/locations/global/workloadIdentityPools/github/providers/go-brianbondy |
| Service account | github-deployer@go-brianbondy.iam.gserviceaccount.com |
| Trust condition | assertion.repository=='bbondy/go-brianbondy' |
The provider only issues credentials to workflows running in this repository, and the service account holds just the roles a deploy needs: appengine.deployer, appengine.serviceAdmin, cloudbuild.builds.editor, storage.admin, artifactregistry.writer, plus iam.serviceAccountUser on the App Engine runtime account.
Mint the Strava refresh token locally, once:
make strava-refresh-tokenThat opens a browser for the Strava consent screen, then prints the refresh token along with the gh secret set commands to store it.
Strava can rotate a refresh token on any refresh. When that happens the workflow writes a note into the run summary saying the stored secret is stale; re-run make strava-refresh-token and update STRAVA_REFRESH_TOKEN.
The Strava scripts refuse to open a browser when CI or STRAVA_NON_INTERACTIVE is set, so a bad credential fails the run with a clear message instead of hanging on an OAuth prompt nobody can answer.