exiguus.blog

Personal blog

Build a Live Projects Page in Zola with GitHub

Created:

Post Permalink

By
exiguus

Introduction

I wanted a projects page that reflects my current GitHub repositories without manually updating Markdown every time I archive, rename, or publish something.

I used GitHub itself as the obvious source of truth.

So instead of maintaining a hand-written list, I built a Zola page that queries the GitHub API during the build and renders repositories into static HTML.

This post explains the pattern.

1. Start with a Separate Page and Template

Like the reads page, the projects page is split into:

  1. a content file
  2. a template file

The content file is intentionally minimal:

+++
title = "Projects"
template = "pages/project.html"
in_search_index = true
authors = ["exiguus"]
+++

This is a List of my public projects on GitHub.

That keeps the page text readable and moves the data logic into the template.

2. Load Repository Data from the GitHub API

I tried GitHub's pinned repositories first, but eventually I wanted the full repository list.

The working approach was the REST API:

{% set github_username = config.extra.link_github_username | default(value="exiguus") %}
{% set github_token = get_env(name="PROJECT_GITHUB_TOKEN", default="") %}

When a token is present, the page loads repositories from /user/repos and keeps the response limited to my owned repositories:

{% set github_response = load_data(
  url="https://api.github.com/user/repos?visibility=all&affiliation=owner&sort=updated&per_page=100",
  format="json",
  headers=[
    "accept=application/vnd.github+json",
    "authorization=Bearer " ~ github_token
  ],
  required=false
) %}

When no token is available, the page falls back to the public endpoint and still fetches the same owner-only repository set:

{% set github_response = load_data(
  url="https://api.github.com/users/" ~ github_username ~ "/repos?type=owner&sort=updated&per_page=100",
  format="json",
  headers=["accept=application/vnd.github+json"],
  required=false
) %}

That gives me a reasonable local and CI setup without making the public version unusable.

3. Use a Dedicated Token Name for the Blog

At first I used GITHUB_TOKEN, but I found that name too easy to confuse with GitHub Actions' built-in token.

I renamed the app-level variable to:

PROJECT_GITHUB_TOKEN=replace-with-your-github-token

This makes the intent clearer:

  • this token is for the projects page
  • it is not the implicit GitHub Actions token
  • it can be wired independently in local builds and CI
  • it can be added as secrets.PROJECT_GITHUB_TOKEN in GitHub Actions

That small naming change removes a surprising amount of confusion.

4. Filter Repositories Before Rendering

The raw GitHub response includes entries I do not want to show by default.

I apply three rules to the page:

  1. exclude forked repositories
  2. separate active and archived repositories
  3. sort by last push activity

Sorting by recent activity uses:

{% for repo in repos | sort(attribute="pushed_at") | reverse %}

Fork filtering uses:

{% if not repo.fork %}

And archived repositories are split into their own section with:

{% if repo.archived %}

That produces a more useful page than just dumping the API response in order.

5. Add a "Most Recent" Section

I also wanted a quick overview at the top, so I open the page with the six most recently updated non-fork repositories:

<section>
  <h2>Most recent active projects</h2>
  ...
</section>

That gives the page a compact summary before the longer active and archived lists.

6. Render Each Repository as an article

I tried figure and figcaption first, but I found article to be a better fit.

Each repository is rendered like this:

<li>
  <article>
    <header>
      <h1>
        <a href="{{ repo.html_url | default(value="#") }}">{{ repo.name | default(value="Unnamed") | regex_replace(pattern="<[^>]*>", rep="") }}</a>
      </h1>
    </header>

    {% if repo.description %}
    <p>{{ repo.description | regex_replace(pattern="<[^>]*>", rep="") }}</p>
    {% endif %}

    <ul>
      <li>URL: {{ repo.html_url }}</li>
      <li>Homepage: {{ repo.homepage }}</li>
      <li>Language: {{ repo.language }}</li>
      <li>First push: {{ repo.created_at }}</li>
      <li>Last push: {{ repo.pushed_at }}</li>
      <li>Stars: {{ repo.stargazers_count }}</li>
      <li>Forks: {{ repo.forks_count }}</li>
    </ul>
  </article>
</li>

That structure is semantically clearer than treating the repo like an image or a captioned media block.

7. Separate Active and Archived Repositories

Once the page had more than a few repositories, mixing active and archived work in one list made the page noisy.

So I split the page into:

  • Most recent projects
  • Active projects
  • Archived projects

Both main sections use <details> with counts in the summary:

<details>
  <summary>Active projects (12)</summary>
  ...
</details>

and

<details>
  <summary>Archived projects (7)</summary>
  ...
</details>

This keeps the page compact while still making everything available.

The full version of the template is in this Zola tera github project page template gist.

8. Local and CI Setup

I keep the page local-friendly by reading PROJECT_GITHUB_TOKEN from apps/blog/.env.

In CI, the same value is passed into the build step from GitHub Actions secrets.

That matters because Zola templates only see environment variables that already exist in the build process. Zola does not load .env files by itself.

My local setup works because the pnpm build and pnpm dev scripts source .env if the file is present.

if [ -f ./.env ]; then set -a && . ./.env && set +a; fi && zola serve --drafts

The GitHub workflows work because the build steps explicitly pass:

  • PROJECT_GITHUB_TOKEN

into the process environment:

- name: Build
  run: pnpm build
  env:
    PROJECT_GITHUB_TOKEN: ${{ secrets.PROJECT_GITHUB_TOKEN }}

Conclusion

If you want a live /projects/ page in Zola, you can build one without a client-side app or a custom export tool.

You can get very far with five things:

  1. a content page
  2. a custom template
  3. load_data
  4. a repository token in the environment
  5. a bit of filtering and sorting in Tera

The end result is still static HTML, but real repository data drives it at build time.

That is exactly the kind of tradeoff I like in Zola: simple publishing, external data where useful, and no unnecessary runtime complexity.

And if you want daily or weekly updates, you can just trigger a rebuild or run a scheduled job of the site and the page will always reflect the current state of your Miniflux feeds.

Resources

Feedback

Have thoughts or experiences you'd like to share? I'd love to hear from you! Whether you agree, disagree, or have a different perspective, your feedback is always welcome. Drop me an email and let's start a conversation.

<​​​​zola-github-projects-page​​​@exiguus​.​​blog​​​>

Tags