Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Release
on:
push:
branches: [main]
pull_request:
release:
types: [published]
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Test (native)
run: go test ./...
- name: Build plugin.wasm
run: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm .
build:
if: github.event_name == 'release'
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Build plugin.wasm
run: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm .
- name: Upload to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG" plugin.wasm --clobber
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
plugin.wasm
*.test
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 1.0.0

- First release: a Projects page (`/projects`) listing a GitHub user's or organization's public repositories (forks and archived repos skipped, most-starred first) plus hand-entered projects from the `extra_projects` setting. Repos are cached in the plugin store and refreshed by an hourly job once older than `cache_hours`.
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,47 @@
# goblog-plugin-projects
goblog plugin: a Projects page listing your GitHub repositories plus hand-entered projects (WebAssembly)

A [goblog](https://github.com/goblogplatform/goblog) WebAssembly plugin that adds a **Projects** page (`/projects`, in the nav) listing your public GitHub repositories (name linked to the repo, description, language and stars, most-starred first) plus any projects you enter by hand.

## Install

From your goblog's **Admin → Plugins → Browse**, search for *Projects* and click **Install** (goblog 0.3.0 or newer). Or download `plugin.wasm` from the [latest release](https://github.com/goblogplatform/goblog-plugin-projects/releases/latest) into `plugins/wasm/projects.wasm`, write `plugins/wasm/projects.json` containing `{"allowed_hosts":["api.github.com"]}`, and restart goblog.

## Settings

Under **Admin → Settings → Projects**:

| Setting | Default | Meaning |
|---|---|---|
| `enabled` | `false` | Set to `true` to enable the Projects page |
| `github_user` | | GitHub user or organization whose public repositories are listed; forks and archived repos are skipped. Leave empty to show only `extra_projects` |
| `github_token` | | Optional; a token (no scopes needed) raises the API rate limit from 60 to 5,000 requests an hour |
| `exclude_repos` | | Comma-separated repository names to hide (case-insensitive) |
| `repo_limit` | `30` | Maximum number of repositories to show (the most-starred N; at most 500) |
| `extra_projects` | | Projects shown above the repos, one per line: `Name \| URL \| Description`. URL and description are optional; blank lines and lines starting with `#` are skipped |
| `cache_hours` | `24` | How long fetched repos are reused before the hourly job refreshes them (`0` or anything invalid means the default) |

For example:

```
goblog.live | https://www.goblog.live | The site this plugin was built for
Thesis | https://example.com/thesis.pdf
```

## How it works

The plugin talks to `api.github.com` only (`/users/{user}/repos`, which also works for organizations), and goblog's sandbox blocks every other host.

Repos are served from the cache: fetched repos are kept in the plugin store (goblog's `plugin_store` table) and the page always shows what is there, so visitors never wait on the API. An hourly `refresh` job re-fetches once the cache is older than `cache_hours`. If GitHub is unreachable the last cached list stays up and the job simply tries again next hour. The page only fetches for itself when the store is empty (first view after install, or after changing `github_user`); if that fetch fails the page shows the extra projects with a note that GitHub projects are temporarily unavailable, and does not retry for ten minutes. Changes to `exclude_repos` and `repo_limit` apply when the cache is next refreshed (within `cache_hours`). `extra_projects` needs no network and changes show up immediately.

## Build it yourself

```bash
go test ./... # unit tests run natively
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm .
```

Needs Go 1.25 or newer (`go.mod` pins the toolchain, so `GOTOOLCHAIN=auto` fetches it). Check the result with `goblog validate-plugin plugin.wasm`. Tag a release as `vX.Y.Z`; the workflow builds and uploads `plugin.wasm` for you.

## License

Apache-2.0.
65 changes: 65 additions & 0 deletions fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package main

import (
"errors"
"fmt"
"net/url"
)

const (
apiBase = "https://api.github.com"
pageSize = 100
)

// getter performs an HTTP GET; the wasm build uses the PDK, tests inject a fake.
type getter func(url string, headers map[string]string) (status int, body []byte, err error)

// maxPages bounds pagination; 10 pages x 100 repos is far beyond any repo_limit.
const maxPages = 10

// fetchAll pages through a user's (or organization's) public repos, drops
// the ones parseReposPage filters, sorts them, and keeps the first limit.
// All pages are read before truncating so "limit" means the top N overall.
func fetchAll(get getter, user, token string, exclude map[string]bool, limit int) ([]Project, error) {
if user == "" {
return nil, errors.New("github user is empty")
}
// GitHub rejects requests without a User-Agent.
headers := map[string]string{"Accept": "application/vnd.github+json", "User-Agent": "goblog-plugin-projects"}
if token != "" {
headers["Authorization"] = "Bearer " + token
}
var out []Project
for page := 1; page <= maxPages; page++ {
u := fmt.Sprintf("%s/users/%s/repos?type=owner&per_page=%d&page=%d", apiBase, url.PathEscape(user), pageSize, page)
status, body, err := get(u, headers)
if err != nil {
return nil, err
}
if status != 200 {
return nil, fmt.Errorf("github returned HTTP %d: %s", status, truncate(string(body), 200))
}
n, ps, err := parseReposPage(body, exclude)
if err != nil {
return nil, fmt.Errorf("decode github response: %w", err)
}
out = append(out, ps...)
if n < pageSize {
break
}
}
sortProjects(out)
if len(out) > limit {
out = out[:limit]
}
return out, nil
}

// truncate cuts s to at most n runes.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
79 changes: 79 additions & 0 deletions fetch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package main

import (
"errors"
"fmt"
"strings"
"testing"
)

// fullPage is pageSize repos, so fetchAll asks for the next page.
func fullPage(prefix string) []byte {
var rs []string
for i := 0; i < pageSize; i++ {
rs = append(rs, fmt.Sprintf(`{"name":"%s%d","stargazers_count":%d}`, prefix, i, i))
}
return []byte("[" + strings.Join(rs, ",") + "]")
}

func TestFetchAll(t *testing.T) {
var calls []string
get := func(url string, headers map[string]string) (int, []byte, error) {
calls = append(calls, url)
if headers["Authorization"] != "Bearer tok" || headers["User-Agent"] == "" {
t.Errorf("headers = %v", headers)
}
if strings.HasSuffix(url, "&page=1") {
return 200, fullPage("a"), nil
}
return 200, []byte(`[{"name":"top","stargazers_count":1000},{"name":"skip","stargazers_count":5000}]`), nil
}
ps, err := fetchAll(get, "some user", "tok", parseExclude("skip"), 3)
if err != nil || len(ps) != 3 {
t.Fatalf("got %d projects, %v", len(ps), err)
}
// limit truncates after every page has been read, so the most-starred
// repo on page 2 still comes first.
if ps[0].Name != "top" || ps[1].Name != "a99" {
t.Errorf("order = %+v", ps)
}
if len(calls) != 2 || !strings.HasPrefix(calls[0], "https://api.github.com/users/some%20user/repos?") || !strings.HasSuffix(calls[1], "&page=2") {
t.Errorf("calls = %v", calls)
}

bad := func(string, map[string]string) (int, []byte, error) { return 403, []byte("rate limited"), nil }
if _, err := fetchAll(bad, "u", "", nil, 5); err == nil || !strings.Contains(err.Error(), "403") {
t.Errorf("403 should error with status, got %v", err)
}
failing := func(string, map[string]string) (int, []byte, error) { return 0, nil, errors.New("net down") }
if _, err := fetchAll(failing, "u", "", nil, 5); err == nil {
t.Error("transport error should propagate")
}
if _, err := fetchAll(get, "", "", nil, 5); err == nil {
t.Error("empty user should error")
}
noToken := func(_ string, headers map[string]string) (int, []byte, error) {
if _, ok := headers["Authorization"]; ok {
t.Errorf("Authorization must be absent without a token: %v", headers)
}
return 200, []byte(`[]`), nil
}
if ps, err := fetchAll(noToken, "u", "", nil, 5); err != nil || len(ps) != 0 {
t.Errorf("no repos: %v %v", ps, err)
}
// full pages forever stop after maxPages
n := 0
forever := func(string, map[string]string) (int, []byte, error) { n++; return 200, fullPage("x"), nil }
if _, err := fetchAll(forever, "u", "", nil, 1); err != nil || n != maxPages {
t.Errorf("endless pagination: %d calls, %v", n, err)
}
}

func TestTruncate(t *testing.T) {
if got := truncate("héllo wörld", 5); got != "héllo…" {
t.Errorf("truncate on rune boundary = %q", got)
}
if got := truncate("short", 10); got != "short" {
t.Errorf("truncate no-op = %q", got)
}
}
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module github.com/goblogplatform/goblog-plugin-projects

go 1.25.0

toolchain go1.26.1

require github.com/extism/go-pdk v1.1.3
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
12 changes: 12 additions & 0 deletions goblog-plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "projects",
"display_name": "Projects",
"description": "A Projects page listing your GitHub repositories, plus any projects you add by hand, cached and refreshed daily.",
"author": "Jason Ernst",
"license": "Apache-2.0",
"runtime": "wasm",
"entry": "plugin.wasm",
"allowed_hosts": ["api.github.com"],
"min_goblog_version": "0.3.0",
"homepage": "https://github.com/goblogplatform/goblog-plugin-projects"
}
Loading
Loading