Initial commit
ae82ac81ffde875bc12a92ba2b32a96231077031
.gitignore
added
+2 -0
| 1 | + | .claude |
|
| 2 | + | /bin |
.gitsigners
added
+1 -0
| 1 | + | alexis ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpDRmIwBm4ajzW+METm9tBdK4CG2/v0qmO4bPfi+s+c alexis@radiant.computer |
AGENTS.md
added
+165 -0
| 1 | + | # Radiant Forge |
|
| 2 | + | ||
| 3 | + | A self-hosted Git repository browser. Single Go binary, static HTML, no JavaScript. |
|
| 4 | + | Uses the `git` CLI for all Git operations. All assets (CSS, fonts, JS, SVG logo, |
|
| 5 | + | templates) are embedded via `//go:embed`. |
|
| 6 | + | ||
| 7 | + | ## Build & Run |
|
| 8 | + | ||
| 9 | + | go build . |
|
| 10 | + | ./forge -scan-path /srv/git -listen :8080 |
|
| 11 | + | ||
| 12 | + | Flags: `-listen`, `-scan-path`, `-title`, `-base-url`, `-non-bare`, |
|
| 13 | + | `-username`, `-password`. |
|
| 14 | + | ||
| 15 | + | ## Project Layout |
|
| 16 | + | ||
| 17 | + | ``` |
|
| 18 | + | main.go Server init, repo scanning, HTTP mux, basic auth |
|
| 19 | + | handler.go All HTTP handlers + route dispatcher |
|
| 20 | + | git.go Types, diff collapsing, MIME map, tree sorting |
|
| 21 | + | git_cli.go Git CLI backend (all git operations shell out to `git`) |
|
| 22 | + | template.go Template loading, embed directives, template FuncMap |
|
| 23 | + | go.mod Module: forge |
|
| 24 | + | static/ |
|
| 25 | + | style.css Single stylesheet, embedded at compile time |
|
| 26 | + | radiant.svg Logo, embedded |
|
| 27 | + | fonts/ 5 TTF files (RethinkSans, IBM Plex Mono), embedded |
|
| 28 | + | js/ Syntax highlighting (hirad.js, hiril.js), embedded |
|
| 29 | + | templates/ |
|
| 30 | + | layout.html Base layout (header, nav, footer, wraps all pages) |
|
| 31 | + | index.html Repository list page |
|
| 32 | + | home.html Repo home: branch selector + file tree + content viewer |
|
| 33 | + | log.html Paginated commit log |
|
| 34 | + | commit.html Commit detail with unified diff |
|
| 35 | + | refs.html Branches and tags tables |
|
| 36 | + | error.html Error page |
|
| 37 | + | ``` |
|
| 38 | + | ||
| 39 | + | ## Architecture |
|
| 40 | + | ||
| 41 | + | ### Server struct (`main.go`) |
|
| 42 | + | ||
| 43 | + | `server` holds: `repos map[string]*RepoInfo`, `sorted []string` (by last update), |
|
| 44 | + | `tmpl *templateSet`, `title`, `baseURL`, `scanPath`, `username`, `password`. |
|
| 45 | + | ||
| 46 | + | Startup: parse flags -> `scanRepositories()` -> `loadTemplates()` -> `http.ListenAndServe`. |
|
| 47 | + | ||
| 48 | + | Repository scanning checks top-level dirs in `scan-path` for bare repos |
|
| 49 | + | (`HEAD` + `objects/` + `refs/`) or non-bare repos (`.git` subdir, opt-in via `-non-bare`). |
|
| 50 | + | Repos are private by default; they are only served if a `public` file exists in the |
|
| 51 | + | git directory. Reads optional `description` and `owner` files from the git directory. |
|
| 52 | + | ||
| 53 | + | Optional HTTP basic auth via `-username` and `-password` (both must be set together). |
|
| 54 | + | ||
| 55 | + | ### Routing (`handler.go`) |
|
| 56 | + | ||
| 57 | + | `route()` is the main dispatcher. URL structure: |
|
| 58 | + | ||
| 59 | + | ``` |
|
| 60 | + | / -> handleIndex (repo list) |
|
| 61 | + | /:repo/ -> handleSummary (repo home) |
|
| 62 | + | /:repo/refs -> handleRefs (branches + tags) |
|
| 63 | + | /:repo/log/:ref?page=N -> handleLog (paginated commit log) |
|
| 64 | + | /:repo/tree/:ref/path... -> handleTree (file/dir browser) |
|
| 65 | + | /:repo/commit/:hash -> handleCommit (commit detail + diff) |
|
| 66 | + | /:repo/raw/:ref/path... -> handleRaw (raw file download) |
|
| 67 | + | /style.css -> serveCSS |
|
| 68 | + | /radiant.svg -> serveLogo |
|
| 69 | + | /js/* -> serveJS |
|
| 70 | + | /fonts/* -> serveFont |
|
| 71 | + | ``` |
|
| 72 | + | ||
| 73 | + | Static assets are registered on the mux directly; everything else goes through `route()`. |
|
| 74 | + | ||
| 75 | + | ### Template data flow |
|
| 76 | + | ||
| 77 | + | All pages receive a `pageData` struct: |
|
| 78 | + | - `SiteTitle`, `BaseURL`, `Repo`, `Description`, `Section`, `Ref`, `CommitHash` |
|
| 79 | + | - `Data any` — page-specific data struct (e.g. `homeData`, `logData`, etc.) |
|
| 80 | + | ||
| 81 | + | Each page template defines `{{define "content"}}` which `layout.html` renders via |
|
| 82 | + | `{{template "content" .}}`. |
|
| 83 | + | ||
| 84 | + | Templates are loaded once at startup. Each page template is parsed together with |
|
| 85 | + | `layout.html` into its own `*template.Template`. Rendered via `templateSet.render()`. |
|
| 86 | + | ||
| 87 | + | ### Template functions (`template.go` FuncMap) |
|
| 88 | + | ||
| 89 | + | `shortHash`, `timeAgo`, `formatDate`, `add`, `diffFileName`, `statusLabel`, |
|
| 90 | + | `formatSize`, `diffBar`, `langClass`, `parentPath`, `indent`, `autolink`. |
|
| 91 | + | ||
| 92 | + | `timeAgo`, `diffBar`, and `autolink` return raw `template.HTML`. |
|
| 93 | + | `indent` returns a CSS `padding-left` style string based on tree depth. |
|
| 94 | + | `autolink` HTML-escapes text and wraps `http://`/`https://` URLs in `<a>` tags. |
|
| 95 | + | ||
| 96 | + | ### Repo home page (`handleSummary` / `handleTree` -> `renderHome`) |
|
| 97 | + | ||
| 98 | + | `renderHome(w, repo, ref, blob, activePath)` is the shared renderer for both the |
|
| 99 | + | summary page and the tree/file browser. |
|
| 100 | + | ||
| 101 | + | - If `ref` is empty, defaults to `getDefaultBranch()` (HEAD's branch name, or first |
|
| 102 | + | available branch, or "main") |
|
| 103 | + | - Resolves the ref to a commit hash |
|
| 104 | + | - Builds the file tree via `buildTreeNodes()` — returns a flat `[]TreeNode` list with |
|
| 105 | + | depth markers, expanding only the directories on the active path |
|
| 106 | + | - Fetches branches via `getBranches()` for the branch selector dropdown |
|
| 107 | + | - Gets README from the active directory (or root) |
|
| 108 | + | - Template data struct: `homeData` with fields `DefaultRef`, `Branches`, |
|
| 109 | + | `Tree`, `Readme`, `LastCommit`, `ActiveBlob`, `ActivePath`, `IsEmpty` |
|
| 110 | + | ||
| 111 | + | The branch selector is a pure-HTML `<details>/<summary>` dropdown (no JS). |
|
| 112 | + | ||
| 113 | + | ### Git operations (`git_cli.go`) |
|
| 114 | + | ||
| 115 | + | All git operations shell out to the `git` CLI via `exec.Command`. This supports |
|
| 116 | + | SHA256 repositories which `go-git` cannot handle. |
|
| 117 | + | ||
| 118 | + | Key functions: |
|
| 119 | + | - `resolveRef(refStr)` — resolve ref name or HEAD to a commit hash |
|
| 120 | + | - `resolveRefAndPath(segments)` — progressively try longer segment prefixes as |
|
| 121 | + | ref names (handles refs with slashes like `release/v1.0`) |
|
| 122 | + | - `getDefaultBranch()` — HEAD's branch name, fallback to first branch, then "main" |
|
| 123 | + | - `getBranches()` / `getTags()` — returns `[]RefInfo` sorted by date descending |
|
| 124 | + | - `getTree(hash, path)` — list directory entries, sorted dirs-first then alphabetical |
|
| 125 | + | - `buildTreeNodes(hash, activePath)` — recursive flat tree for template rendering |
|
| 126 | + | - `getBlob(hash, path)` — file content, up to 1MB, with binary/UTF-8 detection |
|
| 127 | + | - `getRawBlob(hash, path)` — raw `io.ReadCloser` for downloads |
|
| 128 | + | - `getLog(hash, page, perPage)` — paginated commit list |
|
| 129 | + | - `getDiff(hash)` — unified diff with context collapsing (`diffContextLines = 5`) |
|
| 130 | + | - `getCommit(hash)` — single commit info |
|
| 131 | + | - `getReadme(hash, dir)` — finds readme/README.md/README.txt in a directory |
|
| 132 | + | - `isTreePath(hash, path)` — checks if a path is a directory |
|
| 133 | + | ||
| 134 | + | Key types (`git.go`): `RepoInfo`, `CommitInfo`, `TreeNode`, `BlobInfo`, `RefInfo`, |
|
| 135 | + | `DiffFile`, `DiffHunk`, `DiffLine`, `TreeEntryInfo`, `DiffStats`. |
|
| 136 | + | ||
| 137 | + | ### CSS (`static/style.css`) |
|
| 138 | + | ||
| 139 | + | CSS custom properties in `:root` for colors and fonts. Two font families: |
|
| 140 | + | `RethinkSans` (sans-serif, body text) and `IBM Plex Mono` (monospace, code/hashes). |
|
| 141 | + | ||
| 142 | + | Light theme: beige background (`#d3d1d1`), dark text (`#112`), blue links (`#223377`). |
|
| 143 | + | Diff colors: green adds (`#c8e6c9`/`#2e7d32`), red deletes (`#ffcdd2`/`#c62828`). |
|
| 144 | + | ||
| 145 | + | Font sizes are limited to three values: `0.875rem` (small), `1rem` (base), and |
|
| 146 | + | `1.125rem` (large). Do not introduce other font sizes. |
|
| 147 | + | ||
| 148 | + | Margins and padding use `0.25rem` increments (e.g. `0.25rem`, `0.5rem`, `0.75rem`, |
|
| 149 | + | `1rem`, `1.5rem`, `2rem`). Do not use arbitrary values like `0.3rem` or `0.4rem`. |
|
| 150 | + | ||
| 151 | + | No media queries, no dark mode, no responsive breakpoints. |
|
| 152 | + | ||
| 153 | + | ## Conventions |
|
| 154 | + | ||
| 155 | + | - No JavaScript anywhere. All interactivity is pure HTML (links, `<details>`). |
|
| 156 | + | The only JS files are syntax highlighting scripts served as static assets. |
|
| 157 | + | - All assets are embedded — the binary is fully self-contained. |
|
| 158 | + | - Templates use Go's `html/template` with a shared layout pattern. |
|
| 159 | + | - Git operations shell out to the `git` CLI (no `go-git` dependency). |
|
| 160 | + | - Errors in git operations generally return `nil`/empty rather than propagating to the user |
|
| 161 | + | (e.g. `getBranches` errors are silently ignored). |
|
| 162 | + | - Handler functions follow the pattern: build page-specific data struct, wrap in `pageData`, |
|
| 163 | + | call `s.tmpl.render()`. |
|
| 164 | + | - CSS uses a flat structure with class-based selectors, no BEM or similar methodology. |
|
| 165 | + | - Repos must have a `public` file in the git directory to be listed. |
README.md
added
+57 -0
| 1 | + | # source-browser |
|
| 2 | + | ||
| 3 | + | A self-hosted git repository browser, similar to CGit. Single Go binary, |
|
| 4 | + | static HTML, no JavaScript. |
|
| 5 | + | ||
| 6 | + | ## Build |
|
| 7 | + | ||
| 8 | + | go build . |
|
| 9 | + | ||
| 10 | + | ## Usage |
|
| 11 | + | ||
| 12 | + | ./forge [flags] |
|
| 13 | + | ||
| 14 | + | Flags: |
|
| 15 | + | ||
| 16 | + | -scan-path <path> Directory containing bare git repos (default ".") |
|
| 17 | + | -listen <addr> Listen address (default ":8080") |
|
| 18 | + | -title <string> Site title (default "radiant code repositories") |
|
| 19 | + | -base-url <string> Base URL prefix, e.g. "/git" (default "") |
|
| 20 | + | -non-bare Also scan for non-bare repos (dirs containing .git) |
|
| 21 | + | -username <string> HTTP basic auth username (requires -password) |
|
| 22 | + | -password <string> HTTP basic auth password (requires -username) |
|
| 23 | + | ||
| 24 | + | The server scans `scan-path` for bare repositories (directories containing |
|
| 25 | + | `HEAD`, `objects/`, and `refs/`). Repo names are derived from directory |
|
| 26 | + | names with the `.git` suffix stripped. |
|
| 27 | + | ||
| 28 | + | Repositories are **private by default**. A repo is only served if a `public` |
|
| 29 | + | file exists in its Git directory. |
|
| 30 | + | ||
| 31 | + | ## Example |
|
| 32 | + | ||
| 33 | + | Set up some bare repos and start the server: |
|
| 34 | + | ||
| 35 | + | git init --bare /srv/git/example.git |
|
| 36 | + | touch /srv/git/example.git/public |
|
| 37 | + | ./forge -scan-path /srv/git -listen :8080 |
|
| 38 | + | ||
| 39 | + | Then open http://localhost:8080 in a browser. |
|
| 40 | + | ||
| 41 | + | ## URL scheme |
|
| 42 | + | ||
| 43 | + | / Repository index |
|
| 44 | + | /:repo/ Summary (file tree + README) |
|
| 45 | + | /:repo/refs All branches and tags |
|
| 46 | + | /:repo/log/:ref Commit log (paginated, default: HEAD) |
|
| 47 | + | /:repo/tree/:ref/path... Tree/file browser |
|
| 48 | + | /:repo/commit/:hash Commit detail with diff |
|
| 49 | + | /:repo/raw/:ref/path... Raw file download |
|
| 50 | + | ||
| 51 | + | ## Repository metadata |
|
| 52 | + | ||
| 53 | + | The server reads optional metadata files from each bare repo: |
|
| 54 | + | ||
| 55 | + | - `description` -- shown on the index and summary pages |
|
| 56 | + | - `owner` -- shown on the index page |
|
| 57 | + | - `public` -- must exist for the repo to be served (can be empty) |
git.go
added
+724 -0
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "fmt" |
|
| 5 | + | "io" |
|
| 6 | + | "sort" |
|
| 7 | + | "strings" |
|
| 8 | + | "time" |
|
| 9 | + | "unicode/utf8" |
|
| 10 | + | ||
| 11 | + | git "github.com/go-git/go-git/v5" |
|
| 12 | + | "github.com/go-git/go-git/v5/plumbing" |
|
| 13 | + | "github.com/go-git/go-git/v5/plumbing/object" |
|
| 14 | + | ) |
|
| 15 | + | ||
| 16 | + | type RepoInfo struct { |
|
| 17 | + | Name string |
|
| 18 | + | Path string |
|
| 19 | + | Description string |
|
| 20 | + | Owner string |
|
| 21 | + | LastUpdated time.Time |
|
| 22 | + | Repo *git.Repository |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | type CommitInfo struct { |
|
| 26 | + | Hash string |
|
| 27 | + | ShortHash string |
|
| 28 | + | Subject string |
|
| 29 | + | Body string |
|
| 30 | + | Author string |
|
| 31 | + | AuthorEmail string |
|
| 32 | + | AuthorDate time.Time |
|
| 33 | + | Parents []string |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | type TreeEntryInfo struct { |
|
| 37 | + | Name string |
|
| 38 | + | IsDir bool |
|
| 39 | + | Size int64 |
|
| 40 | + | Mode string |
|
| 41 | + | Hash string |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | type BlobInfo struct { |
|
| 45 | + | Name string |
|
| 46 | + | Size int64 |
|
| 47 | + | IsBinary bool |
|
| 48 | + | Lines []string |
|
| 49 | + | Content string |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | type RefInfo struct { |
|
| 53 | + | Name string |
|
| 54 | + | Hash string |
|
| 55 | + | ShortHash string |
|
| 56 | + | Subject string |
|
| 57 | + | Author string |
|
| 58 | + | Date time.Time |
|
| 59 | + | IsTag bool |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | type DiffFile struct { |
|
| 63 | + | OldName string |
|
| 64 | + | NewName string |
|
| 65 | + | Status string |
|
| 66 | + | Hunks []DiffHunk |
|
| 67 | + | IsBinary bool |
|
| 68 | + | Stats DiffStats |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | type DiffStats struct { |
|
| 72 | + | Added int |
|
| 73 | + | Deleted int |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | type DiffHunk struct { |
|
| 77 | + | Header string |
|
| 78 | + | Lines []DiffLine |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | type DiffLine struct { |
|
| 82 | + | Type string // "context", "add", "del" |
|
| 83 | + | Content string |
|
| 84 | + | OldNum int |
|
| 85 | + | NewNum int |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | const maxBlobDisplaySize = 1 << 20 // 1MB |
|
| 89 | + | const diffContextLines = 5 |
|
| 90 | + | ||
| 91 | + | func commitToInfo(c *object.Commit) CommitInfo { |
|
| 92 | + | subject, body, _ := strings.Cut(c.Message, "\n") |
|
| 93 | + | body = strings.TrimSpace(body) |
|
| 94 | + | parents := make([]string, len(c.ParentHashes)) |
|
| 95 | + | for i, p := range c.ParentHashes { |
|
| 96 | + | parents[i] = p.String() |
|
| 97 | + | } |
|
| 98 | + | return CommitInfo{ |
|
| 99 | + | Hash: c.Hash.String(), |
|
| 100 | + | ShortHash: c.Hash.String()[:8], |
|
| 101 | + | Subject: subject, |
|
| 102 | + | Body: body, |
|
| 103 | + | Author: c.Author.Name, |
|
| 104 | + | AuthorEmail: c.Author.Email, |
|
| 105 | + | AuthorDate: c.Author.When, |
|
| 106 | + | Parents: parents, |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | func resolveRef(repo *git.Repository, refStr string) (*plumbing.Hash, error) { |
|
| 111 | + | if refStr == "" { |
|
| 112 | + | head, err := repo.Head() |
|
| 113 | + | if err != nil { |
|
| 114 | + | return nil, err |
|
| 115 | + | } |
|
| 116 | + | h := head.Hash() |
|
| 117 | + | return &h, nil |
|
| 118 | + | } |
|
| 119 | + | h, err := repo.ResolveRevision(plumbing.Revision(refStr)) |
|
| 120 | + | if err != nil { |
|
| 121 | + | return nil, err |
|
| 122 | + | } |
|
| 123 | + | return h, nil |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | func resolveRefAndPath(repo *git.Repository, segments []string) (hash *plumbing.Hash, ref string, path string, err error) { |
|
| 127 | + | // Try progressively longer prefixes as ref names |
|
| 128 | + | for i := 1; i <= len(segments); i++ { |
|
| 129 | + | candidate := strings.Join(segments[:i], "/") |
|
| 130 | + | h, err := resolveRef(repo, candidate) |
|
| 131 | + | if err == nil { |
|
| 132 | + | rest := "" |
|
| 133 | + | if i < len(segments) { |
|
| 134 | + | rest = strings.Join(segments[i:], "/") |
|
| 135 | + | } |
|
| 136 | + | return h, candidate, rest, nil |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | return nil, "", "", fmt.Errorf("cannot resolve ref from path segments: %s", strings.Join(segments, "/")) |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | func getDefaultBranch(repo *git.Repository) string { |
|
| 143 | + | head, err := repo.Head() |
|
| 144 | + | if err != nil { |
|
| 145 | + | return "main" |
|
| 146 | + | } |
|
| 147 | + | name := head.Name() |
|
| 148 | + | if name.IsBranch() { |
|
| 149 | + | return name.Short() |
|
| 150 | + | } |
|
| 151 | + | return "main" |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | func getLog(repo *git.Repository, hash plumbing.Hash, page, perPage int) ([]CommitInfo, bool, error) { |
|
| 155 | + | iter, err := repo.Log(&git.LogOptions{From: hash}) |
|
| 156 | + | if err != nil { |
|
| 157 | + | return nil, false, err |
|
| 158 | + | } |
|
| 159 | + | defer iter.Close() |
|
| 160 | + | ||
| 161 | + | skip := page * perPage |
|
| 162 | + | for i := 0; i < skip; i++ { |
|
| 163 | + | if _, err := iter.Next(); err != nil { |
|
| 164 | + | return nil, false, nil |
|
| 165 | + | } |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | var commits []CommitInfo |
|
| 169 | + | for i := 0; i < perPage+1; i++ { |
|
| 170 | + | c, err := iter.Next() |
|
| 171 | + | if err == io.EOF { |
|
| 172 | + | break |
|
| 173 | + | } |
|
| 174 | + | if err != nil { |
|
| 175 | + | return nil, false, err |
|
| 176 | + | } |
|
| 177 | + | if i == perPage { |
|
| 178 | + | return commits, true, nil |
|
| 179 | + | } |
|
| 180 | + | commits = append(commits, commitToInfo(c)) |
|
| 181 | + | } |
|
| 182 | + | return commits, false, nil |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | func getTree(repo *git.Repository, hash plumbing.Hash, path string) ([]TreeEntryInfo, error) { |
|
| 186 | + | commit, err := repo.CommitObject(hash) |
|
| 187 | + | if err != nil { |
|
| 188 | + | return nil, err |
|
| 189 | + | } |
|
| 190 | + | tree, err := commit.Tree() |
|
| 191 | + | if err != nil { |
|
| 192 | + | return nil, err |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | if path != "" { |
|
| 196 | + | tree, err = tree.Tree(path) |
|
| 197 | + | if err != nil { |
|
| 198 | + | return nil, err |
|
| 199 | + | } |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | var entries []TreeEntryInfo |
|
| 203 | + | for _, e := range tree.Entries { |
|
| 204 | + | isDir := !e.Mode.IsFile() |
|
| 205 | + | var size int64 |
|
| 206 | + | if !isDir { |
|
| 207 | + | f, err := tree.TreeEntryFile(&e) |
|
| 208 | + | if err == nil { |
|
| 209 | + | size = f.Size |
|
| 210 | + | } |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | mode := fmt.Sprintf("%06o", uint32(e.Mode)) |
|
| 214 | + | ||
| 215 | + | entries = append(entries, TreeEntryInfo{ |
|
| 216 | + | Name: e.Name, |
|
| 217 | + | IsDir: isDir, |
|
| 218 | + | Size: size, |
|
| 219 | + | Mode: mode, |
|
| 220 | + | Hash: e.Hash.String()[:8], |
|
| 221 | + | }) |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | sort.Slice(entries, func(i, j int) bool { |
|
| 225 | + | if entries[i].IsDir != entries[j].IsDir { |
|
| 226 | + | return entries[i].IsDir |
|
| 227 | + | } |
|
| 228 | + | return entries[i].Name < entries[j].Name |
|
| 229 | + | }) |
|
| 230 | + | ||
| 231 | + | return entries, nil |
|
| 232 | + | } |
|
| 233 | + | ||
| 234 | + | func getBlob(repo *git.Repository, hash plumbing.Hash, path string) (*BlobInfo, error) { |
|
| 235 | + | commit, err := repo.CommitObject(hash) |
|
| 236 | + | if err != nil { |
|
| 237 | + | return nil, err |
|
| 238 | + | } |
|
| 239 | + | tree, err := commit.Tree() |
|
| 240 | + | if err != nil { |
|
| 241 | + | return nil, err |
|
| 242 | + | } |
|
| 243 | + | f, err := tree.File(path) |
|
| 244 | + | if err != nil { |
|
| 245 | + | return nil, err |
|
| 246 | + | } |
|
| 247 | + | ||
| 248 | + | info := &BlobInfo{ |
|
| 249 | + | Name: f.Name, |
|
| 250 | + | Size: f.Size, |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | isBin, err := f.IsBinary() |
|
| 254 | + | if err != nil { |
|
| 255 | + | return nil, err |
|
| 256 | + | } |
|
| 257 | + | info.IsBinary = isBin |
|
| 258 | + | ||
| 259 | + | if isBin || f.Size > maxBlobDisplaySize { |
|
| 260 | + | info.IsBinary = true |
|
| 261 | + | return info, nil |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | content, err := f.Contents() |
|
| 265 | + | if err != nil { |
|
| 266 | + | return nil, err |
|
| 267 | + | } |
|
| 268 | + | ||
| 269 | + | if !utf8.ValidString(content) { |
|
| 270 | + | info.IsBinary = true |
|
| 271 | + | return info, nil |
|
| 272 | + | } |
|
| 273 | + | ||
| 274 | + | info.Content = content |
|
| 275 | + | info.Lines = strings.Split(content, "\n") |
|
| 276 | + | // Remove trailing empty line from final newline |
|
| 277 | + | if len(info.Lines) > 0 && info.Lines[len(info.Lines)-1] == "" { |
|
| 278 | + | info.Lines = info.Lines[:len(info.Lines)-1] |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | return info, nil |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | func getRawBlob(repo *git.Repository, hash plumbing.Hash, path string) (io.ReadCloser, string, int64, error) { |
|
| 285 | + | commit, err := repo.CommitObject(hash) |
|
| 286 | + | if err != nil { |
|
| 287 | + | return nil, "", 0, err |
|
| 288 | + | } |
|
| 289 | + | tree, err := commit.Tree() |
|
| 290 | + | if err != nil { |
|
| 291 | + | return nil, "", 0, err |
|
| 292 | + | } |
|
| 293 | + | f, err := tree.File(path) |
|
| 294 | + | if err != nil { |
|
| 295 | + | return nil, "", 0, err |
|
| 296 | + | } |
|
| 297 | + | ||
| 298 | + | reader, err := f.Reader() |
|
| 299 | + | if err != nil { |
|
| 300 | + | return nil, "", 0, err |
|
| 301 | + | } |
|
| 302 | + | ||
| 303 | + | // Detect content type from name |
|
| 304 | + | ct := "application/octet-stream" |
|
| 305 | + | if mtype := mimeFromPath(path); mtype != "" { |
|
| 306 | + | ct = mtype |
|
| 307 | + | } |
|
| 308 | + | ||
| 309 | + | return reader, ct, f.Size, nil |
|
| 310 | + | } |
|
| 311 | + | ||
| 312 | + | var mimeTypes = map[string]string{ |
|
| 313 | + | ".txt": "text/plain; charset=utf-8", |
|
| 314 | + | ".md": "text/plain; charset=utf-8", |
|
| 315 | + | ".go": "text/plain; charset=utf-8", |
|
| 316 | + | ".rs": "text/plain; charset=utf-8", |
|
| 317 | + | ".py": "text/plain; charset=utf-8", |
|
| 318 | + | ".js": "text/plain; charset=utf-8", |
|
| 319 | + | ".ts": "text/plain; charset=utf-8", |
|
| 320 | + | ".c": "text/plain; charset=utf-8", |
|
| 321 | + | ".h": "text/plain; charset=utf-8", |
|
| 322 | + | ".css": "text/css; charset=utf-8", |
|
| 323 | + | ".html": "text/html; charset=utf-8", |
|
| 324 | + | ".json": "application/json", |
|
| 325 | + | ".xml": "application/xml", |
|
| 326 | + | ".png": "image/png", |
|
| 327 | + | ".jpg": "image/jpeg", |
|
| 328 | + | ".jpeg": "image/jpeg", |
|
| 329 | + | ".gif": "image/gif", |
|
| 330 | + | ".svg": "image/svg+xml", |
|
| 331 | + | ".pdf": "application/pdf", |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | func mimeFromPath(path string) string { |
|
| 335 | + | // Use a small set of common types; let the browser figure out the rest |
|
| 336 | + | idx := strings.LastIndex(path, ".") |
|
| 337 | + | if idx < 0 { |
|
| 338 | + | return "" |
|
| 339 | + | } |
|
| 340 | + | ext := strings.ToLower(path[idx:]) |
|
| 341 | + | if t, ok := mimeTypes[ext]; ok { |
|
| 342 | + | return t |
|
| 343 | + | } |
|
| 344 | + | return "" |
|
| 345 | + | } |
|
| 346 | + | ||
| 347 | + | func getCommit(repo *git.Repository, hash plumbing.Hash) (*CommitInfo, error) { |
|
| 348 | + | c, err := repo.CommitObject(hash) |
|
| 349 | + | if err != nil { |
|
| 350 | + | return nil, err |
|
| 351 | + | } |
|
| 352 | + | info := commitToInfo(c) |
|
| 353 | + | return &info, nil |
|
| 354 | + | } |
|
| 355 | + | ||
| 356 | + | func getDiff(repo *git.Repository, hash plumbing.Hash) ([]DiffFile, error) { |
|
| 357 | + | commit, err := repo.CommitObject(hash) |
|
| 358 | + | if err != nil { |
|
| 359 | + | return nil, err |
|
| 360 | + | } |
|
| 361 | + | ||
| 362 | + | var parentTree *object.Tree |
|
| 363 | + | if commit.NumParents() > 0 { |
|
| 364 | + | parent, err := commit.Parent(0) |
|
| 365 | + | if err != nil { |
|
| 366 | + | return nil, err |
|
| 367 | + | } |
|
| 368 | + | parentTree, err = parent.Tree() |
|
| 369 | + | if err != nil { |
|
| 370 | + | return nil, err |
|
| 371 | + | } |
|
| 372 | + | } |
|
| 373 | + | ||
| 374 | + | commitTree, err := commit.Tree() |
|
| 375 | + | if err != nil { |
|
| 376 | + | return nil, err |
|
| 377 | + | } |
|
| 378 | + | ||
| 379 | + | if parentTree == nil { |
|
| 380 | + | // Initial commit: diff against empty tree |
|
| 381 | + | changes, err := object.DiffTree(nil, commitTree) |
|
| 382 | + | if err != nil { |
|
| 383 | + | return nil, err |
|
| 384 | + | } |
|
| 385 | + | return changesToDiffFiles(changes) |
|
| 386 | + | } |
|
| 387 | + | ||
| 388 | + | changes, err := object.DiffTree(parentTree, commitTree) |
|
| 389 | + | if err != nil { |
|
| 390 | + | return nil, err |
|
| 391 | + | } |
|
| 392 | + | return changesToDiffFiles(changes) |
|
| 393 | + | } |
|
| 394 | + | ||
| 395 | + | func changesToDiffFiles(changes object.Changes) ([]DiffFile, error) { |
|
| 396 | + | patch, err := changes.Patch() |
|
| 397 | + | if err != nil { |
|
| 398 | + | return nil, err |
|
| 399 | + | } |
|
| 400 | + | ||
| 401 | + | var files []DiffFile |
|
| 402 | + | for _, fp := range patch.FilePatches() { |
|
| 403 | + | from, to := fp.Files() |
|
| 404 | + | df := DiffFile{ |
|
| 405 | + | IsBinary: fp.IsBinary(), |
|
| 406 | + | } |
|
| 407 | + | ||
| 408 | + | if from != nil { |
|
| 409 | + | df.OldName = from.Path() |
|
| 410 | + | } |
|
| 411 | + | if to != nil { |
|
| 412 | + | df.NewName = to.Path() |
|
| 413 | + | } |
|
| 414 | + | ||
| 415 | + | switch { |
|
| 416 | + | case from == nil && to != nil: |
|
| 417 | + | df.Status = "A" |
|
| 418 | + | case from != nil && to == nil: |
|
| 419 | + | df.Status = "D" |
|
| 420 | + | case from != nil && to != nil && from.Path() != to.Path(): |
|
| 421 | + | df.Status = "R" |
|
| 422 | + | default: |
|
| 423 | + | df.Status = "M" |
|
| 424 | + | } |
|
| 425 | + | ||
| 426 | + | if !fp.IsBinary() { |
|
| 427 | + | oldNum, newNum := 1, 1 |
|
| 428 | + | for _, chunk := range fp.Chunks() { |
|
| 429 | + | lines := strings.Split(chunk.Content(), "\n") |
|
| 430 | + | // Remove trailing empty string from split |
|
| 431 | + | if len(lines) > 0 && lines[len(lines)-1] == "" { |
|
| 432 | + | lines = lines[:len(lines)-1] |
|
| 433 | + | } |
|
| 434 | + | ||
| 435 | + | for _, line := range lines { |
|
| 436 | + | if len(df.Hunks) == 0 { |
|
| 437 | + | df.Hunks = append(df.Hunks, DiffHunk{}) |
|
| 438 | + | } |
|
| 439 | + | last := &df.Hunks[len(df.Hunks)-1] |
|
| 440 | + | ||
| 441 | + | switch chunk.Type() { |
|
| 442 | + | case 0: // Equal |
|
| 443 | + | last.Lines = append(last.Lines, DiffLine{Type: "context", Content: line, OldNum: oldNum, NewNum: newNum}) |
|
| 444 | + | oldNum++ |
|
| 445 | + | newNum++ |
|
| 446 | + | case 1: // Add |
|
| 447 | + | df.Stats.Added++ |
|
| 448 | + | last.Lines = append(last.Lines, DiffLine{Type: "add", Content: line, NewNum: newNum}) |
|
| 449 | + | newNum++ |
|
| 450 | + | case 2: // Delete |
|
| 451 | + | df.Stats.Deleted++ |
|
| 452 | + | last.Lines = append(last.Lines, DiffLine{Type: "del", Content: line, OldNum: oldNum}) |
|
| 453 | + | oldNum++ |
|
| 454 | + | } |
|
| 455 | + | } |
|
| 456 | + | } |
|
| 457 | + | } |
|
| 458 | + | ||
| 459 | + | // Collapse context lines, keeping only diffContextLines around changes. |
|
| 460 | + | if len(df.Hunks) == 1 { |
|
| 461 | + | df.Hunks = collapseContext(df.Hunks[0].Lines) |
|
| 462 | + | } |
|
| 463 | + | ||
| 464 | + | files = append(files, df) |
|
| 465 | + | } |
|
| 466 | + | return files, nil |
|
| 467 | + | } |
|
| 468 | + | ||
| 469 | + | // collapseContext takes a flat list of diff lines and splits them into |
|
| 470 | + | // hunks, keeping only diffContextLines of context around add/del lines. |
|
| 471 | + | func collapseContext(lines []DiffLine) []DiffHunk { |
|
| 472 | + | if len(lines) == 0 { |
|
| 473 | + | return nil |
|
| 474 | + | } |
|
| 475 | + | ||
| 476 | + | // Mark which lines to keep: diffContextLines around each changed line. |
|
| 477 | + | keep := make([]bool, len(lines)) |
|
| 478 | + | for i, l := range lines { |
|
| 479 | + | if l.Type == "add" || l.Type == "del" { |
|
| 480 | + | start := i - diffContextLines |
|
| 481 | + | if start < 0 { |
|
| 482 | + | start = 0 |
|
| 483 | + | } |
|
| 484 | + | end := i + diffContextLines |
|
| 485 | + | if end >= len(lines) { |
|
| 486 | + | end = len(lines) - 1 |
|
| 487 | + | } |
|
| 488 | + | for j := start; j <= end; j++ { |
|
| 489 | + | keep[j] = true |
|
| 490 | + | } |
|
| 491 | + | } |
|
| 492 | + | } |
|
| 493 | + | ||
| 494 | + | // Build hunks from contiguous kept ranges. |
|
| 495 | + | var hunks []DiffHunk |
|
| 496 | + | var current *DiffHunk |
|
| 497 | + | for i, l := range lines { |
|
| 498 | + | if keep[i] { |
|
| 499 | + | if current == nil { |
|
| 500 | + | current = &DiffHunk{} |
|
| 501 | + | } |
|
| 502 | + | current.Lines = append(current.Lines, l) |
|
| 503 | + | } else { |
|
| 504 | + | if current != nil { |
|
| 505 | + | hunks = append(hunks, *current) |
|
| 506 | + | current = nil |
|
| 507 | + | } |
|
| 508 | + | } |
|
| 509 | + | } |
|
| 510 | + | if current != nil { |
|
| 511 | + | hunks = append(hunks, *current) |
|
| 512 | + | } |
|
| 513 | + | ||
| 514 | + | return hunks |
|
| 515 | + | } |
|
| 516 | + | ||
| 517 | + | func getBranches(repo *git.Repository) ([]RefInfo, error) { |
|
| 518 | + | iter, err := repo.Branches() |
|
| 519 | + | if err != nil { |
|
| 520 | + | return nil, err |
|
| 521 | + | } |
|
| 522 | + | defer iter.Close() |
|
| 523 | + | ||
| 524 | + | var refs []RefInfo |
|
| 525 | + | err = iter.ForEach(func(ref *plumbing.Reference) error { |
|
| 526 | + | c, err := repo.CommitObject(ref.Hash()) |
|
| 527 | + | if err != nil { |
|
| 528 | + | return nil |
|
| 529 | + | } |
|
| 530 | + | refs = append(refs, RefInfo{ |
|
| 531 | + | Name: ref.Name().Short(), |
|
| 532 | + | Hash: ref.Hash().String(), |
|
| 533 | + | ShortHash: ref.Hash().String()[:8], |
|
| 534 | + | Subject: firstLine(c.Message), |
|
| 535 | + | Author: c.Author.Name, |
|
| 536 | + | Date: c.Author.When, |
|
| 537 | + | }) |
|
| 538 | + | return nil |
|
| 539 | + | }) |
|
| 540 | + | ||
| 541 | + | sort.Slice(refs, func(i, j int) bool { |
|
| 542 | + | return refs[i].Date.After(refs[j].Date) |
|
| 543 | + | }) |
|
| 544 | + | ||
| 545 | + | return refs, err |
|
| 546 | + | } |
|
| 547 | + | ||
| 548 | + | func getTags(repo *git.Repository) ([]RefInfo, error) { |
|
| 549 | + | iter, err := repo.Tags() |
|
| 550 | + | if err != nil { |
|
| 551 | + | return nil, err |
|
| 552 | + | } |
|
| 553 | + | defer iter.Close() |
|
| 554 | + | ||
| 555 | + | var refs []RefInfo |
|
| 556 | + | err = iter.ForEach(func(ref *plumbing.Reference) error { |
|
| 557 | + | hash := ref.Hash() |
|
| 558 | + | ||
| 559 | + | // Try to peel annotated tag |
|
| 560 | + | tagObj, err := repo.TagObject(hash) |
|
| 561 | + | if err == nil { |
|
| 562 | + | commit, err := tagObj.Commit() |
|
| 563 | + | if err == nil { |
|
| 564 | + | refs = append(refs, RefInfo{ |
|
| 565 | + | Name: ref.Name().Short(), |
|
| 566 | + | Hash: commit.Hash.String(), |
|
| 567 | + | ShortHash: commit.Hash.String()[:8], |
|
| 568 | + | Subject: firstLine(commit.Message), |
|
| 569 | + | Author: commit.Author.Name, |
|
| 570 | + | Date: commit.Author.When, |
|
| 571 | + | IsTag: true, |
|
| 572 | + | }) |
|
| 573 | + | return nil |
|
| 574 | + | } |
|
| 575 | + | } |
|
| 576 | + | ||
| 577 | + | // Lightweight tag - try as commit directly |
|
| 578 | + | c, err := repo.CommitObject(hash) |
|
| 579 | + | if err != nil { |
|
| 580 | + | return nil |
|
| 581 | + | } |
|
| 582 | + | refs = append(refs, RefInfo{ |
|
| 583 | + | Name: ref.Name().Short(), |
|
| 584 | + | Hash: hash.String(), |
|
| 585 | + | ShortHash: hash.String()[:8], |
|
| 586 | + | Subject: firstLine(c.Message), |
|
| 587 | + | Author: c.Author.Name, |
|
| 588 | + | Date: c.Author.When, |
|
| 589 | + | IsTag: true, |
|
| 590 | + | }) |
|
| 591 | + | return nil |
|
| 592 | + | }) |
|
| 593 | + | ||
| 594 | + | sort.Slice(refs, func(i, j int) bool { |
|
| 595 | + | return refs[i].Date.After(refs[j].Date) |
|
| 596 | + | }) |
|
| 597 | + | ||
| 598 | + | return refs, err |
|
| 599 | + | } |
|
| 600 | + | ||
| 601 | + | // isTreePath checks if a path in a commit is a directory |
|
| 602 | + | func isTreePath(repo *git.Repository, hash plumbing.Hash, path string) bool { |
|
| 603 | + | commit, err := repo.CommitObject(hash) |
|
| 604 | + | if err != nil { |
|
| 605 | + | return false |
|
| 606 | + | } |
|
| 607 | + | tree, err := commit.Tree() |
|
| 608 | + | if err != nil { |
|
| 609 | + | return false |
|
| 610 | + | } |
|
| 611 | + | if path == "" { |
|
| 612 | + | return true |
|
| 613 | + | } |
|
| 614 | + | _, err = tree.Tree(path) |
|
| 615 | + | return err == nil |
|
| 616 | + | } |
|
| 617 | + | ||
| 618 | + | type TreeNode struct { |
|
| 619 | + | Name string |
|
| 620 | + | IsDir bool |
|
| 621 | + | Size int64 |
|
| 622 | + | Path string |
|
| 623 | + | Depth int |
|
| 624 | + | IsOpen bool |
|
| 625 | + | IsActive bool |
|
| 626 | + | } |
|
| 627 | + | ||
| 628 | + | func buildTreeNodes(repo *git.Repository, hash plumbing.Hash, activePath string) []TreeNode { |
|
| 629 | + | var activeComponents []string |
|
| 630 | + | if activePath != "" { |
|
| 631 | + | activeComponents = strings.Split(activePath, "/") |
|
| 632 | + | } |
|
| 633 | + | return buildTreeLevel(repo, hash, "", 0, activeComponents) |
|
| 634 | + | } |
|
| 635 | + | ||
| 636 | + | func buildTreeLevel(repo *git.Repository, hash plumbing.Hash, basePath string, depth int, activeComponents []string) []TreeNode { |
|
| 637 | + | entries, err := getTree(repo, hash, basePath) |
|
| 638 | + | if err != nil { |
|
| 639 | + | return nil |
|
| 640 | + | } |
|
| 641 | + | ||
| 642 | + | var nodes []TreeNode |
|
| 643 | + | for _, e := range entries { |
|
| 644 | + | fullPath := e.Name |
|
| 645 | + | if basePath != "" { |
|
| 646 | + | fullPath = basePath + "/" + e.Name |
|
| 647 | + | } |
|
| 648 | + | ||
| 649 | + | isOpen := false |
|
| 650 | + | isActive := false |
|
| 651 | + | ||
| 652 | + | if len(activeComponents) > 0 && e.Name == activeComponents[0] { |
|
| 653 | + | if e.IsDir { |
|
| 654 | + | isOpen = true |
|
| 655 | + | if len(activeComponents) == 1 { |
|
| 656 | + | isActive = true |
|
| 657 | + | } |
|
| 658 | + | } else if len(activeComponents) == 1 { |
|
| 659 | + | isActive = true |
|
| 660 | + | } |
|
| 661 | + | } |
|
| 662 | + | ||
| 663 | + | nodes = append(nodes, TreeNode{ |
|
| 664 | + | Name: e.Name, |
|
| 665 | + | IsDir: e.IsDir, |
|
| 666 | + | Size: e.Size, |
|
| 667 | + | Path: fullPath, |
|
| 668 | + | Depth: depth, |
|
| 669 | + | IsOpen: isOpen, |
|
| 670 | + | IsActive: isActive, |
|
| 671 | + | }) |
|
| 672 | + | ||
| 673 | + | if isOpen { |
|
| 674 | + | var childActive []string |
|
| 675 | + | if len(activeComponents) > 1 { |
|
| 676 | + | childActive = activeComponents[1:] |
|
| 677 | + | } |
|
| 678 | + | children := buildTreeLevel(repo, hash, fullPath, depth+1, childActive) |
|
| 679 | + | nodes = append(nodes, children...) |
|
| 680 | + | } |
|
| 681 | + | } |
|
| 682 | + | ||
| 683 | + | return nodes |
|
| 684 | + | } |
|
| 685 | + | ||
| 686 | + | func getReadme(repo *git.Repository, hash plumbing.Hash, dir string) *BlobInfo { |
|
| 687 | + | commit, err := repo.CommitObject(hash) |
|
| 688 | + | if err != nil { |
|
| 689 | + | return nil |
|
| 690 | + | } |
|
| 691 | + | root, err := commit.Tree() |
|
| 692 | + | if err != nil { |
|
| 693 | + | return nil |
|
| 694 | + | } |
|
| 695 | + | tree := root |
|
| 696 | + | if dir != "" { |
|
| 697 | + | tree, err = root.Tree(dir) |
|
| 698 | + | if err != nil { |
|
| 699 | + | return nil |
|
| 700 | + | } |
|
| 701 | + | } |
|
| 702 | + | for _, entry := range tree.Entries { |
|
| 703 | + | lower := strings.ToLower(entry.Name) |
|
| 704 | + | if lower == "readme" || lower == "readme.md" || lower == "readme.txt" { |
|
| 705 | + | path := entry.Name |
|
| 706 | + | if dir != "" { |
|
| 707 | + | path = dir + "/" + entry.Name |
|
| 708 | + | } |
|
| 709 | + | blob, err := getBlob(repo, hash, path) |
|
| 710 | + | if err != nil { |
|
| 711 | + | return nil |
|
| 712 | + | } |
|
| 713 | + | return blob |
|
| 714 | + | } |
|
| 715 | + | } |
|
| 716 | + | return nil |
|
| 717 | + | } |
|
| 718 | + | ||
| 719 | + | func firstLine(s string) string { |
|
| 720 | + | if i := strings.IndexByte(s, '\n'); i >= 0 { |
|
| 721 | + | return s[:i] |
|
| 722 | + | } |
|
| 723 | + | return s |
|
| 724 | + | } |
go.mod
added
+27 -0
| 1 | + | module source-browser |
|
| 2 | + | ||
| 3 | + | go 1.25.7 |
|
| 4 | + | ||
| 5 | + | require github.com/go-git/go-git/v5 v5.16.5 |
|
| 6 | + | ||
| 7 | + | require ( |
|
| 8 | + | dario.cat/mergo v1.0.0 // indirect |
|
| 9 | + | github.com/Microsoft/go-winio v0.6.2 // indirect |
|
| 10 | + | github.com/ProtonMail/go-crypto v1.1.6 // indirect |
|
| 11 | + | github.com/cloudflare/circl v1.6.1 // indirect |
|
| 12 | + | github.com/cyphar/filepath-securejoin v0.4.1 // indirect |
|
| 13 | + | github.com/emirpasic/gods v1.18.1 // indirect |
|
| 14 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect |
|
| 15 | + | github.com/go-git/go-billy/v5 v5.6.2 // indirect |
|
| 16 | + | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect |
|
| 17 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect |
|
| 18 | + | github.com/kevinburke/ssh_config v1.2.0 // indirect |
|
| 19 | + | github.com/pjbgf/sha1cd v0.3.2 // indirect |
|
| 20 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect |
|
| 21 | + | github.com/skeema/knownhosts v1.3.1 // indirect |
|
| 22 | + | github.com/xanzy/ssh-agent v0.3.3 // indirect |
|
| 23 | + | golang.org/x/crypto v0.45.0 // indirect |
|
| 24 | + | golang.org/x/net v0.47.0 // indirect |
|
| 25 | + | golang.org/x/sys v0.38.0 // indirect |
|
| 26 | + | gopkg.in/warnings.v0 v0.1.2 // indirect |
|
| 27 | + | ) |
go.sum
added
+102 -0
| 1 | + | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= |
|
| 2 | + | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= |
|
| 3 | + | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= |
|
| 4 | + | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= |
|
| 5 | + | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= |
|
| 6 | + | github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= |
|
| 7 | + | github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= |
|
| 8 | + | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= |
|
| 9 | + | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= |
|
| 10 | + | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= |
|
| 11 | + | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= |
|
| 12 | + | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= |
|
| 13 | + | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= |
|
| 14 | + | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= |
|
| 15 | + | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= |
|
| 16 | + | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
| 17 | + | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|
| 18 | + | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
| 19 | + | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= |
|
| 20 | + | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= |
|
| 21 | + | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= |
|
| 22 | + | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= |
|
| 23 | + | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= |
|
| 24 | + | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= |
|
| 25 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= |
|
| 26 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= |
|
| 27 | + | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= |
|
| 28 | + | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= |
|
| 29 | + | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= |
|
| 30 | + | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= |
|
| 31 | + | github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= |
|
| 32 | + | github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= |
|
| 33 | + | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= |
|
| 34 | + | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= |
|
| 35 | + | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
|
| 36 | + | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
|
| 37 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= |
|
| 38 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= |
|
| 39 | + | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= |
|
| 40 | + | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= |
|
| 41 | + | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= |
|
| 42 | + | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= |
|
| 43 | + | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= |
|
| 44 | + | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= |
|
| 45 | + | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= |
|
| 46 | + | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= |
|
| 47 | + | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= |
|
| 48 | + | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= |
|
| 49 | + | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= |
|
| 50 | + | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= |
|
| 51 | + | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= |
|
| 52 | + | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= |
|
| 53 | + | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= |
|
| 54 | + | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|
| 55 | + | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|
| 56 | + | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= |
|
| 57 | + | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= |
|
| 58 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= |
|
| 59 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= |
|
| 60 | + | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= |
|
| 61 | + | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= |
|
| 62 | + | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= |
|
| 63 | + | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|
| 64 | + | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= |
|
| 65 | + | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= |
|
| 66 | + | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= |
|
| 67 | + | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= |
|
| 68 | + | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= |
|
| 69 | + | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= |
|
| 70 | + | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= |
|
| 71 | + | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= |
|
| 72 | + | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= |
|
| 73 | + | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= |
|
| 74 | + | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= |
|
| 75 | + | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= |
|
| 76 | + | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= |
|
| 77 | + | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= |
|
| 78 | + | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 79 | + | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 80 | + | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 81 | + | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 82 | + | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 83 | + | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 84 | + | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= |
|
| 85 | + | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= |
|
| 86 | + | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
|
| 87 | + | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= |
|
| 88 | + | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= |
|
| 89 | + | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
|
| 90 | + | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= |
|
| 91 | + | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= |
|
| 92 | + | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
|
| 93 | + | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|
| 94 | + | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|
| 95 | + | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= |
|
| 96 | + | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= |
|
| 97 | + | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= |
|
| 98 | + | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= |
|
| 99 | + | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= |
|
| 100 | + | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= |
|
| 101 | + | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
|
| 102 | + | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
handler.go
added
+360 -0
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "fmt" |
|
| 5 | + | "io" |
|
| 6 | + | "net/http" |
|
| 7 | + | "strconv" |
|
| 8 | + | "strings" |
|
| 9 | + | ||
| 10 | + | "github.com/go-git/go-git/v5/plumbing" |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | type pageData struct { |
|
| 14 | + | SiteTitle string |
|
| 15 | + | BaseURL string |
|
| 16 | + | Repo string |
|
| 17 | + | Description string |
|
| 18 | + | Section string |
|
| 19 | + | Ref string |
|
| 20 | + | CommitHash string |
|
| 21 | + | Data any |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | func (s *server) newPageData(repo *RepoInfo, section, ref string) pageData { |
|
| 25 | + | pd := pageData{ |
|
| 26 | + | SiteTitle: s.title, |
|
| 27 | + | BaseURL: s.baseURL, |
|
| 28 | + | Section: section, |
|
| 29 | + | Ref: ref, |
|
| 30 | + | } |
|
| 31 | + | if repo != nil { |
|
| 32 | + | pd.Repo = repo.Name |
|
| 33 | + | pd.Description = repo.Description |
|
| 34 | + | } |
|
| 35 | + | return pd |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | func (s *server) serveCSS(w http.ResponseWriter, r *http.Request) { |
|
| 39 | + | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
|
| 40 | + | w.Header().Set("Cache-Control", "public, max-age=3600") |
|
| 41 | + | w.Write(cssContent) |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | func (s *server) serveLogo(w http.ResponseWriter, r *http.Request) { |
|
| 45 | + | w.Header().Set("Content-Type", "image/svg+xml") |
|
| 46 | + | w.Header().Set("Cache-Control", "public, max-age=86400") |
|
| 47 | + | w.Write(logoContent) |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | func (s *server) serveJS(w http.ResponseWriter, r *http.Request) { |
|
| 51 | + | name := strings.TrimPrefix(r.URL.Path, "/js/") |
|
| 52 | + | var data []byte |
|
| 53 | + | switch name { |
|
| 54 | + | case "hirad.js": |
|
| 55 | + | data = hiradJS |
|
| 56 | + | case "hiril.js": |
|
| 57 | + | data = hirilJS |
|
| 58 | + | default: |
|
| 59 | + | http.NotFound(w, r) |
|
| 60 | + | return |
|
| 61 | + | } |
|
| 62 | + | w.Header().Set("Content-Type", "application/javascript; charset=utf-8") |
|
| 63 | + | w.Header().Set("Cache-Control", "public, max-age=3600") |
|
| 64 | + | w.Write(data) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | func (s *server) serveFont(w http.ResponseWriter, r *http.Request) { |
|
| 68 | + | name := strings.TrimPrefix(r.URL.Path, "/fonts/") |
|
| 69 | + | data, err := fontsFS.ReadFile("static/fonts/" + name) |
|
| 70 | + | if err != nil { |
|
| 71 | + | http.NotFound(w, r) |
|
| 72 | + | return |
|
| 73 | + | } |
|
| 74 | + | w.Header().Set("Content-Type", "font/ttf") |
|
| 75 | + | w.Header().Set("Cache-Control", "public, max-age=86400") |
|
| 76 | + | w.Write(data) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | func (s *server) route(w http.ResponseWriter, r *http.Request) { |
|
| 80 | + | path := strings.TrimPrefix(r.URL.Path, s.baseURL) |
|
| 81 | + | path = strings.Trim(path, "/") |
|
| 82 | + | ||
| 83 | + | if path == "" { |
|
| 84 | + | s.handleIndex(w, r) |
|
| 85 | + | return |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | segments := strings.SplitN(path, "/", 3) |
|
| 89 | + | repoName := segments[0] |
|
| 90 | + | ||
| 91 | + | repo, ok := s.repos[repoName] |
|
| 92 | + | if !ok { |
|
| 93 | + | s.renderError(w, r, http.StatusNotFound, "Repository not found") |
|
| 94 | + | return |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | if len(segments) == 1 { |
|
| 98 | + | s.handleSummary(w, r, repo) |
|
| 99 | + | return |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | action := segments[1] |
|
| 103 | + | rest := "" |
|
| 104 | + | if len(segments) > 2 { |
|
| 105 | + | rest = segments[2] |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | switch action { |
|
| 109 | + | case "refs": |
|
| 110 | + | s.handleRefs(w, r, repo) |
|
| 111 | + | case "log": |
|
| 112 | + | s.handleLog(w, r, repo, rest) |
|
| 113 | + | case "tree": |
|
| 114 | + | s.handleTree(w, r, repo, rest) |
|
| 115 | + | case "commit": |
|
| 116 | + | s.handleCommit(w, r, repo, rest) |
|
| 117 | + | case "raw": |
|
| 118 | + | s.handleRaw(w, r, repo, rest) |
|
| 119 | + | default: |
|
| 120 | + | s.renderError(w, r, http.StatusNotFound, "Page not found") |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { |
|
| 125 | + | type indexData struct { |
|
| 126 | + | Repos []*RepoInfo |
|
| 127 | + | IsEmpty bool |
|
| 128 | + | } |
|
| 129 | + | repos := make([]*RepoInfo, 0, len(s.sorted)) |
|
| 130 | + | for _, name := range s.sorted { |
|
| 131 | + | repos = append(repos, s.repos[name]) |
|
| 132 | + | } |
|
| 133 | + | pd := s.newPageData(nil, "", "") |
|
| 134 | + | pd.Data = indexData{Repos: repos, IsEmpty: len(repos) == 0} |
|
| 135 | + | s.tmpl.render(w, "index", pd) |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | type homeData struct { |
|
| 139 | + | DefaultRef string |
|
| 140 | + | Branches []RefInfo |
|
| 141 | + | Tree []TreeNode |
|
| 142 | + | Readme *BlobInfo |
|
| 143 | + | LastCommit *CommitInfo |
|
| 144 | + | ActiveBlob *BlobInfo |
|
| 145 | + | ActivePath string |
|
| 146 | + | IsEmpty bool |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | func (s *server) handleSummary(w http.ResponseWriter, r *http.Request, repo *RepoInfo) { |
|
| 150 | + | s.renderHome(w, repo, "", nil, "") |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | func (s *server) renderHome(w http.ResponseWriter, repo *RepoInfo, ref string, blob *BlobInfo, activePath string) { |
|
| 154 | + | if ref == "" { |
|
| 155 | + | ref = getDefaultBranch(repo.Repo) |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | head, err := resolveRef(repo.Repo, ref) |
|
| 159 | + | if err != nil { |
|
| 160 | + | pd := s.newPageData(repo, "home", ref) |
|
| 161 | + | pd.Data = homeData{ |
|
| 162 | + | DefaultRef: ref, |
|
| 163 | + | IsEmpty: true, |
|
| 164 | + | } |
|
| 165 | + | s.tmpl.render(w, "home", pd) |
|
| 166 | + | return |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | tree := buildTreeNodes(repo.Repo, *head, activePath) |
|
| 170 | + | lastCommit, _ := getCommit(repo.Repo, *head) |
|
| 171 | + | branches, _ := getBranches(repo.Repo) |
|
| 172 | + | ||
| 173 | + | // Show README for the active directory (or root if no active path / file selected) |
|
| 174 | + | readmeDir := "" |
|
| 175 | + | if activePath != "" && blob == nil { |
|
| 176 | + | readmeDir = activePath |
|
| 177 | + | } |
|
| 178 | + | readme := getReadme(repo.Repo, *head, readmeDir) |
|
| 179 | + | ||
| 180 | + | pd := s.newPageData(repo, "home", ref) |
|
| 181 | + | pd.Data = homeData{ |
|
| 182 | + | DefaultRef: ref, |
|
| 183 | + | Branches: branches, |
|
| 184 | + | Tree: tree, |
|
| 185 | + | Readme: readme, |
|
| 186 | + | LastCommit: lastCommit, |
|
| 187 | + | ActiveBlob: blob, |
|
| 188 | + | ActivePath: activePath, |
|
| 189 | + | } |
|
| 190 | + | s.tmpl.render(w, "home", pd) |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | func (s *server) handleLog(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { |
|
| 194 | + | type logData struct { |
|
| 195 | + | Branches []RefInfo |
|
| 196 | + | LastCommit *CommitInfo |
|
| 197 | + | Commits []CommitInfo |
|
| 198 | + | Page int |
|
| 199 | + | HasPrev bool |
|
| 200 | + | HasNext bool |
|
| 201 | + | PrevPage int |
|
| 202 | + | NextPage int |
|
| 203 | + | Ref string |
|
| 204 | + | IsEmpty bool |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | page := 0 |
|
| 208 | + | if p := r.URL.Query().Get("page"); p != "" { |
|
| 209 | + | if n, err := strconv.Atoi(p); err == nil && n >= 0 { |
|
| 210 | + | page = n |
|
| 211 | + | } |
|
| 212 | + | } |
|
| 213 | + | ||
| 214 | + | refStr := rest |
|
| 215 | + | if refStr == "" { |
|
| 216 | + | refStr = getDefaultBranch(repo.Repo) |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | hash, err := resolveRef(repo.Repo, refStr) |
|
| 220 | + | if err != nil { |
|
| 221 | + | pd := s.newPageData(repo, "log", refStr) |
|
| 222 | + | pd.Data = logData{IsEmpty: true, Ref: refStr} |
|
| 223 | + | s.tmpl.render(w, "log", pd) |
|
| 224 | + | return |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | commits, hasMore, err := getLog(repo.Repo, *hash, page, 50) |
|
| 228 | + | if err != nil { |
|
| 229 | + | s.renderError(w, r, http.StatusInternalServerError, err.Error()) |
|
| 230 | + | return |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | branches, _ := getBranches(repo.Repo) |
|
| 234 | + | lastCommit, _ := getCommit(repo.Repo, *hash) |
|
| 235 | + | ||
| 236 | + | pd := s.newPageData(repo, "log", refStr) |
|
| 237 | + | pd.Data = logData{ |
|
| 238 | + | Branches: branches, |
|
| 239 | + | LastCommit: lastCommit, |
|
| 240 | + | Commits: commits, |
|
| 241 | + | Page: page, |
|
| 242 | + | HasPrev: page > 0, |
|
| 243 | + | HasNext: hasMore, |
|
| 244 | + | PrevPage: page - 1, |
|
| 245 | + | NextPage: page + 1, |
|
| 246 | + | Ref: refStr, |
|
| 247 | + | IsEmpty: len(commits) == 0, |
|
| 248 | + | } |
|
| 249 | + | s.tmpl.render(w, "log", pd) |
|
| 250 | + | } |
|
| 251 | + | ||
| 252 | + | func (s *server) handleTree(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { |
|
| 253 | + | if rest == "" { |
|
| 254 | + | rest = getDefaultBranch(repo.Repo) |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | segments := strings.Split(rest, "/") |
|
| 258 | + | hash, ref, path, err := resolveRefAndPath(repo.Repo, segments) |
|
| 259 | + | if err != nil { |
|
| 260 | + | s.renderError(w, r, http.StatusNotFound, "Ref not found") |
|
| 261 | + | return |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | // File: show blob in content view |
|
| 265 | + | if path != "" && !isTreePath(repo.Repo, *hash, path) { |
|
| 266 | + | blob, err := getBlob(repo.Repo, *hash, path) |
|
| 267 | + | if err != nil { |
|
| 268 | + | s.renderError(w, r, http.StatusNotFound, "File not found") |
|
| 269 | + | return |
|
| 270 | + | } |
|
| 271 | + | s.renderHome(w, repo, ref, blob, path) |
|
| 272 | + | return |
|
| 273 | + | } |
|
| 274 | + | ||
| 275 | + | // Directory: expand tree to this path |
|
| 276 | + | s.renderHome(w, repo, ref, nil, path) |
|
| 277 | + | } |
|
| 278 | + | ||
| 279 | + | func (s *server) handleCommit(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { |
|
| 280 | + | type commitData struct { |
|
| 281 | + | Commit *CommitInfo |
|
| 282 | + | Files []DiffFile |
|
| 283 | + | } |
|
| 284 | + | ||
| 285 | + | hash := plumbing.NewHash(rest) |
|
| 286 | + | commit, err := getCommit(repo.Repo, hash) |
|
| 287 | + | if err != nil { |
|
| 288 | + | s.renderError(w, r, http.StatusNotFound, "Commit not found") |
|
| 289 | + | return |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | files, err := getDiff(repo.Repo, hash) |
|
| 293 | + | if err != nil { |
|
| 294 | + | s.renderError(w, r, http.StatusInternalServerError, err.Error()) |
|
| 295 | + | return |
|
| 296 | + | } |
|
| 297 | + | ||
| 298 | + | pd := s.newPageData(repo, "commit", "") |
|
| 299 | + | pd.CommitHash = commit.Hash |
|
| 300 | + | pd.Data = commitData{ |
|
| 301 | + | Commit: commit, |
|
| 302 | + | Files: files, |
|
| 303 | + | } |
|
| 304 | + | s.tmpl.render(w, "commit", pd) |
|
| 305 | + | } |
|
| 306 | + | ||
| 307 | + | func (s *server) handleRefs(w http.ResponseWriter, r *http.Request, repo *RepoInfo) { |
|
| 308 | + | type refsData struct { |
|
| 309 | + | Branches []RefInfo |
|
| 310 | + | Tags []RefInfo |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | branches, _ := getBranches(repo.Repo) |
|
| 314 | + | tags, _ := getTags(repo.Repo) |
|
| 315 | + | ||
| 316 | + | pd := s.newPageData(repo, "refs", "") |
|
| 317 | + | pd.Data = refsData{Branches: branches, Tags: tags} |
|
| 318 | + | s.tmpl.render(w, "refs", pd) |
|
| 319 | + | } |
|
| 320 | + | ||
| 321 | + | func (s *server) handleRaw(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { |
|
| 322 | + | if rest == "" { |
|
| 323 | + | s.renderError(w, r, http.StatusNotFound, "No path specified") |
|
| 324 | + | return |
|
| 325 | + | } |
|
| 326 | + | ||
| 327 | + | segments := strings.Split(rest, "/") |
|
| 328 | + | hash, _, path, err := resolveRefAndPath(repo.Repo, segments) |
|
| 329 | + | if err != nil { |
|
| 330 | + | s.renderError(w, r, http.StatusNotFound, "Ref not found") |
|
| 331 | + | return |
|
| 332 | + | } |
|
| 333 | + | if path == "" { |
|
| 334 | + | s.renderError(w, r, http.StatusNotFound, "No file path") |
|
| 335 | + | return |
|
| 336 | + | } |
|
| 337 | + | ||
| 338 | + | reader, contentType, size, err := getRawBlob(repo.Repo, *hash, path) |
|
| 339 | + | if err != nil { |
|
| 340 | + | s.renderError(w, r, http.StatusNotFound, "File not found") |
|
| 341 | + | return |
|
| 342 | + | } |
|
| 343 | + | defer reader.Close() |
|
| 344 | + | ||
| 345 | + | w.Header().Set("Content-Type", contentType) |
|
| 346 | + | w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) |
|
| 347 | + | io.Copy(w, reader) |
|
| 348 | + | } |
|
| 349 | + | ||
| 350 | + | func (s *server) renderError(w http.ResponseWriter, r *http.Request, code int, message string) { |
|
| 351 | + | type errorData struct { |
|
| 352 | + | Code int |
|
| 353 | + | Message string |
|
| 354 | + | Path string |
|
| 355 | + | } |
|
| 356 | + | w.WriteHeader(code) |
|
| 357 | + | pd := s.newPageData(nil, "", "") |
|
| 358 | + | pd.Data = errorData{Code: code, Message: message, Path: r.URL.Path} |
|
| 359 | + | s.tmpl.render(w, "error", pd) |
|
| 360 | + | } |
main.go
added
+196 -0
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "flag" |
|
| 5 | + | "fmt" |
|
| 6 | + | "log" |
|
| 7 | + | "net/http" |
|
| 8 | + | "os" |
|
| 9 | + | "path/filepath" |
|
| 10 | + | "sort" |
|
| 11 | + | "strings" |
|
| 12 | + | "time" |
|
| 13 | + | ||
| 14 | + | git "github.com/go-git/go-git/v5" |
|
| 15 | + | ) |
|
| 16 | + | ||
| 17 | + | type server struct { |
|
| 18 | + | repos map[string]*RepoInfo |
|
| 19 | + | sorted []string |
|
| 20 | + | tmpl *templateSet |
|
| 21 | + | title string |
|
| 22 | + | baseURL string |
|
| 23 | + | scanPath string |
|
| 24 | + | username string |
|
| 25 | + | password string |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | func main() { |
|
| 29 | + | listen := flag.String("listen", ":8080", "listen address") |
|
| 30 | + | scanPath := flag.String("scan-path", ".", "path to scan for git repos") |
|
| 31 | + | title := flag.String("title", "radiant computer repositories", "site title") |
|
| 32 | + | baseURL := flag.String("base-url", "", "base URL prefix (e.g. /git)") |
|
| 33 | + | nonBare := flag.Bool("non-bare", false, "also scan for non-bare repos (dirs containing .git)") |
|
| 34 | + | username := flag.String("username", "", "HTTP basic auth username (requires -password)") |
|
| 35 | + | password := flag.String("password", "", "HTTP basic auth password (requires -username)") |
|
| 36 | + | flag.Parse() |
|
| 37 | + | ||
| 38 | + | if (*username == "") != (*password == "") { |
|
| 39 | + | log.Fatal("-username and -password must both be set, or both be omitted") |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | abs, err := filepath.Abs(*scanPath) |
|
| 43 | + | if err != nil { |
|
| 44 | + | log.Fatalf("invalid scan path: %v", err) |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | repos, err := scanRepositories(abs, *nonBare) |
|
| 48 | + | if err != nil { |
|
| 49 | + | log.Fatalf("scan repositories: %v", err) |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | sorted := make([]string, 0, len(repos)) |
|
| 53 | + | for name := range repos { |
|
| 54 | + | sorted = append(sorted, name) |
|
| 55 | + | } |
|
| 56 | + | sort.Slice(sorted, func(i, j int) bool { |
|
| 57 | + | return repos[sorted[i]].LastUpdated.After(repos[sorted[j]].LastUpdated) |
|
| 58 | + | }) |
|
| 59 | + | ||
| 60 | + | tmpl, err := loadTemplates() |
|
| 61 | + | if err != nil { |
|
| 62 | + | log.Fatalf("load templates: %v", err) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | srv := &server{ |
|
| 66 | + | repos: repos, |
|
| 67 | + | sorted: sorted, |
|
| 68 | + | tmpl: tmpl, |
|
| 69 | + | title: *title, |
|
| 70 | + | baseURL: strings.TrimRight(*baseURL, "/"), |
|
| 71 | + | scanPath: abs, |
|
| 72 | + | username: *username, |
|
| 73 | + | password: *password, |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | mux := http.NewServeMux() |
|
| 77 | + | mux.HandleFunc("/style.css", srv.serveCSS) |
|
| 78 | + | mux.HandleFunc("/radiant.svg", srv.serveLogo) |
|
| 79 | + | mux.HandleFunc("/js/", srv.serveJS) |
|
| 80 | + | mux.HandleFunc("/fonts/", srv.serveFont) |
|
| 81 | + | mux.HandleFunc("/", srv.route) |
|
| 82 | + | ||
| 83 | + | var handler http.Handler = mux |
|
| 84 | + | if srv.username != "" { |
|
| 85 | + | handler = srv.basicAuth(mux) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | log.Printf("listening on %s (scanning %s, %d repos)", *listen, abs, len(repos)) |
|
| 89 | + | if err := http.ListenAndServe(*listen, handler); err != nil { |
|
| 90 | + | log.Fatal(err) |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | func (s *server) basicAuth(next http.Handler) http.Handler { |
|
| 95 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|
| 96 | + | u, p, ok := r.BasicAuth() |
|
| 97 | + | if !ok || u != s.username || p != s.password { |
|
| 98 | + | w.Header().Set("WWW-Authenticate", `Basic realm="source-browser"`) |
|
| 99 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|
| 100 | + | return |
|
| 101 | + | } |
|
| 102 | + | next.ServeHTTP(w, r) |
|
| 103 | + | }) |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | func scanRepositories(root string, nonBare bool) (map[string]*RepoInfo, error) { |
|
| 107 | + | repos := make(map[string]*RepoInfo) |
|
| 108 | + | ||
| 109 | + | entries, err := os.ReadDir(root) |
|
| 110 | + | if err != nil { |
|
| 111 | + | return nil, fmt.Errorf("read dir %s: %w", root, err) |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | for _, entry := range entries { |
|
| 115 | + | if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { |
|
| 116 | + | continue |
|
| 117 | + | } |
|
| 118 | + | dirPath := filepath.Join(root, entry.Name()) |
|
| 119 | + | ||
| 120 | + | var repo *git.Repository |
|
| 121 | + | var gitDir string |
|
| 122 | + | ||
| 123 | + | if isBareRepo(dirPath) { |
|
| 124 | + | gitDir = dirPath |
|
| 125 | + | } else if nonBare && isWorkTreeRepo(dirPath) { |
|
| 126 | + | gitDir = filepath.Join(dirPath, ".git") |
|
| 127 | + | } else { |
|
| 128 | + | continue |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | // Repos are private by default; skip unless a 'public' file exists. |
|
| 132 | + | if _, err := os.Stat(filepath.Join(gitDir, "public")); err != nil { |
|
| 133 | + | continue |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | repo, err = git.PlainOpen(dirPath) |
|
| 137 | + | if err != nil { |
|
| 138 | + | log.Printf("skip %s: %v", entry.Name(), err) |
|
| 139 | + | continue |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | name := strings.TrimSuffix(entry.Name(), ".git") |
|
| 143 | + | ||
| 144 | + | desc := "" |
|
| 145 | + | descBytes, err := os.ReadFile(filepath.Join(gitDir, "description")) |
|
| 146 | + | if err == nil { |
|
| 147 | + | d := strings.TrimSpace(string(descBytes)) |
|
| 148 | + | if d != "Unnamed repository; edit this file 'description' to name the repository." { |
|
| 149 | + | desc = d |
|
| 150 | + | } |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | owner := "" |
|
| 154 | + | ownerBytes, err := os.ReadFile(filepath.Join(gitDir, "owner")) |
|
| 155 | + | if err == nil { |
|
| 156 | + | owner = strings.TrimSpace(string(ownerBytes)) |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | var lastUpdated time.Time |
|
| 160 | + | head, err := repo.Head() |
|
| 161 | + | if err == nil { |
|
| 162 | + | c, err := repo.CommitObject(head.Hash()) |
|
| 163 | + | if err == nil { |
|
| 164 | + | lastUpdated = c.Author.When |
|
| 165 | + | } |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | repos[name] = &RepoInfo{ |
|
| 169 | + | Name: name, |
|
| 170 | + | Path: dirPath, |
|
| 171 | + | Description: desc, |
|
| 172 | + | Owner: owner, |
|
| 173 | + | LastUpdated: lastUpdated, |
|
| 174 | + | Repo: repo, |
|
| 175 | + | } |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | return repos, nil |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | func isWorkTreeRepo(path string) bool { |
|
| 182 | + | info, err := os.Stat(filepath.Join(path, ".git")) |
|
| 183 | + | if err != nil { |
|
| 184 | + | return false |
|
| 185 | + | } |
|
| 186 | + | return info.IsDir() |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | func isBareRepo(path string) bool { |
|
| 190 | + | for _, f := range []string{"HEAD", "objects", "refs"} { |
|
| 191 | + | if _, err := os.Stat(filepath.Join(path, f)); err != nil { |
|
| 192 | + | return false |
|
| 193 | + | } |
|
| 194 | + | } |
|
| 195 | + | return true |
|
| 196 | + | } |
static/fonts/IBMPlexMono-Italic.ttf
added
+0 -0
Binary file changed.
static/fonts/IBMPlexMono-Regular.ttf
added
+0 -0
Binary file changed.
static/fonts/IBMPlexMono-SemiBold.ttf
added
+0 -0
Binary file changed.
static/fonts/RethinkSans-Italic.ttf
added
+0 -0
Binary file changed.
static/fonts/RethinkSans.ttf
added
+0 -0
Binary file changed.
static/js/hirad.js
added
+82 -0
| 1 | + | (function (hirad) { |
|
| 2 | + | // |
|
| 3 | + | // hirad - Radiance Syntax Highlighter |
|
| 4 | + | // |
|
| 5 | + | // Copyright (c) 2020-2025 Alexis Sellier |
|
| 6 | + | // |
|
| 7 | + | const selector = hirad || '.language-radiance'; |
|
| 8 | + | const keywords = [ |
|
| 9 | + | 'fn', 'pub', 'if', 'else', 'for', 'while', 'break', 'switch', 'match', |
|
| 10 | + | 'record', 'union', 'const', 'align', 'let', 'use', 'mod', 'case', |
|
| 11 | + | 'continue', 'return', 'true', 'false', 'loop', 'extern', 'panic', |
|
| 12 | + | 'device', 'register', 'bit', 'catch', 'throw', 'throws', 'test', |
|
| 13 | + | 'at', 'mut', 'nil', 'undefined', 'static', 'in', 'is', 'where', |
|
| 14 | + | 'as', 'and', 'or', 'xor', 'not', 'try', 'atomic', 'select' |
|
| 15 | + | ]; |
|
| 16 | + | const types = ['bool', 'u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'f32', 'void', 'opaque']; |
|
| 17 | + | ||
| 18 | + | // Syntax definition. |
|
| 19 | + | // |
|
| 20 | + | // The key becomes the class name of the span around the matched block of code. |
|
| 21 | + | const syntax = [ |
|
| 22 | + | ['comment', /(\/\/[^\n]*)/g], |
|
| 23 | + | ['string' , /("(?:(?!").|\\.)*"|'[^']{1,2}')/g], |
|
| 24 | + | ['number' , /\b(0x[0-9a-fA-F]+|0b[01]+|[0-9]+(?:\.[0-9]+)?)\b/g], |
|
| 25 | + | ['ref' , /(&|&'|\*)\b/g], |
|
| 26 | + | ['delim' , /(->|=>|\(|\)|\{|\}|\[|\])/g], |
|
| 27 | + | ['builtin', /(@[a-zA-Z]+)/g], |
|
| 28 | + | ['access' , /(\.|::)/g], |
|
| 29 | + | ['op' , /(=|!=|\.\.|\+|-|\*|\/|%|\?{1,2}|!{1,2}|>=?|<=?)/g], |
|
| 30 | + | ['keyword', new RegExp('\\b(' + keywords.join('|') + ')\\b', 'g')], |
|
| 31 | + | ['type' , new RegExp('\\b(' + types.join('|') + ')\\b', 'g')], |
|
| 32 | + | ]; |
|
| 33 | + | ||
| 34 | + | const table = {}; |
|
| 35 | + | ||
| 36 | + | // Encode ASCII characters to Braille to avoid conflicts between patterns. |
|
| 37 | + | const encode = (str) => { |
|
| 38 | + | const encoded = [...str].map(c => |
|
| 39 | + | c.charCodeAt(0) <= 127 ? String.fromCharCode(c.charCodeAt(0) + 0x2800) : c |
|
| 40 | + | ).join(''); |
|
| 41 | + | table[encoded] = str; |
|
| 42 | + | ||
| 43 | + | return encoded; |
|
| 44 | + | }; |
|
| 45 | + | ||
| 46 | + | // Decode Braille back to ASCII. |
|
| 47 | + | const decode = (str) => |
|
| 48 | + | table[str] || [...str].map(c => { |
|
| 49 | + | const code = c.charCodeAt(0) - 0x2800; |
|
| 50 | + | return code >= 0 && code <= 127 ? String.fromCharCode(code) : c; |
|
| 51 | + | }).join(''); |
|
| 52 | + | ||
| 53 | + | // Escape HTML special characters. |
|
| 54 | + | const escape = (str) => |
|
| 55 | + | str.replace(/</g, '<').replace(/>/g, '>'); |
|
| 56 | + | ||
| 57 | + | // Highlight all matching elements. |
|
| 58 | + | for (const node of document.querySelectorAll(selector)) { |
|
| 59 | + | for (const child of node.childNodes) { |
|
| 60 | + | if (child.nodeType !== Node.TEXT_NODE) continue; |
|
| 61 | + | if (/^\$\s/.test(child.nodeValue.trim())) continue; // Skip shell snippets. |
|
| 62 | + | ||
| 63 | + | for (const [cls, re] of syntax) { |
|
| 64 | + | child.nodeValue = child.nodeValue.replace(re, (_, m) => |
|
| 65 | + | '\u00ab' + encode(cls) + '\u00b7' + |
|
| 66 | + | encode(m) + |
|
| 67 | + | '\u00b7' + encode(cls) + '\u00bb' |
|
| 68 | + | ); |
|
| 69 | + | } |
|
| 70 | + | } |
|
| 71 | + | node.innerHTML = node.innerHTML.replace( |
|
| 72 | + | /\u00ab(.+?)\u00b7(.+?)\u00b7\1\u00bb/g, |
|
| 73 | + | (_, name, value) => { |
|
| 74 | + | value = value.replace(/\u00ab[^\u00b7]+\u00b7|\u00b7[^\u00bb]+\u00bb/g, ''); |
|
| 75 | + | return '<span class="' + decode(name) + '">' + |
|
| 76 | + | escape(decode(value)) + |
|
| 77 | + | '</span>'; |
|
| 78 | + | } |
|
| 79 | + | ); |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | })(window.hirad); |
static/js/hiril.js
added
+87 -0
| 1 | + | (function (hiril) { |
|
| 2 | + | // |
|
| 3 | + | // hiril - Radiance IL Syntax Highlighter |
|
| 4 | + | // |
|
| 5 | + | // Copyright (c) 2020-2025 Alexis Sellier |
|
| 6 | + | // |
|
| 7 | + | const selector = hiril || '.language-ril'; |
|
| 8 | + | const keywords = ['fn', 'data', 'extern', 'mut', 'align']; |
|
| 9 | + | const instrs = [ |
|
| 10 | + | 'reserve', 'load', 'sload', 'store', 'blit', 'copy', |
|
| 11 | + | 'add', 'sub', 'mul', 'sdiv', 'udiv', 'srem', 'urem', 'neg', |
|
| 12 | + | 'eq', 'ne', 'slt', 'sge', 'ult', 'uge', |
|
| 13 | + | 'and', 'or', 'xor', 'shl', 'sshr', 'ushr', 'not', |
|
| 14 | + | 'zext', 'sext', 'call', 'ret', 'jmp', |
|
| 15 | + | 'switch', 'unreachable', 'ecall', 'ebreak' |
|
| 16 | + | ]; |
|
| 17 | + | const branchOps = ['br.eq', 'br.ne', 'br.slt', 'br.ult']; |
|
| 18 | + | const dataItems = ['str', 'sym', 'undef']; |
|
| 19 | + | const types = ['w8', 'w16', 'w32', 'w64']; |
|
| 20 | + | ||
| 21 | + | // Syntax definition. |
|
| 22 | + | // |
|
| 23 | + | // The key becomes the class name of the span around the matched block of code. |
|
| 24 | + | const syntax = [ |
|
| 25 | + | ['comment', /(\/\/[^\n]*)/g], |
|
| 26 | + | ['string' , /("[^"]*")/g], |
|
| 27 | + | ['reg' , /(%[0-9]+)/g], |
|
| 28 | + | ['label' , /(@[A-Za-z_][A-Za-z#0-9_]*)/g], |
|
| 29 | + | ['symbol' , /(\$[A-Za-z_][A-Za-z0-9_]*)/g], |
|
| 30 | + | ['number' , /\b(-?[0-9]+|0x[a-fA-F0-9]+)\b/g], |
|
| 31 | + | ['delim' , /([{}();:,])/g], |
|
| 32 | + | ['keyword', new RegExp('\\b(' + keywords.join('|') + ')\\b', 'g')], |
|
| 33 | + | ['instr' , new RegExp('\\b(' + branchOps.join('|').replace(/\./g, '\\.') + ')\\b', 'g')], |
|
| 34 | + | ['instr' , new RegExp('\\b(' + instrs.join('|') + ')\\b', 'g')], |
|
| 35 | + | ['type' , new RegExp('\\b(' + dataItems.join('|') + ')\\b', 'g')], |
|
| 36 | + | ['type' , new RegExp('\\b(' + types.join('|') + ')\\b', 'g')], |
|
| 37 | + | ]; |
|
| 38 | + | ||
| 39 | + | const table = {}; |
|
| 40 | + | ||
| 41 | + | // Encode ASCII characters to Braille to avoid conflicts between patterns. |
|
| 42 | + | const encode = (str) => { |
|
| 43 | + | const encoded = [...str].map(c => |
|
| 44 | + | c.charCodeAt(0) <= 127 ? String.fromCharCode(c.charCodeAt(0) + 0x2800) : c |
|
| 45 | + | ).join(''); |
|
| 46 | + | table[encoded] = str; |
|
| 47 | + | ||
| 48 | + | return encoded; |
|
| 49 | + | }; |
|
| 50 | + | ||
| 51 | + | // Decode Braille back to ASCII. |
|
| 52 | + | const decode = (str) => |
|
| 53 | + | table[str] || [...str].map(c => { |
|
| 54 | + | const code = c.charCodeAt(0) - 0x2800; |
|
| 55 | + | return code >= 0 && code <= 127 ? String.fromCharCode(code) : c; |
|
| 56 | + | }).join(''); |
|
| 57 | + | ||
| 58 | + | // Escape HTML special characters. |
|
| 59 | + | const escape = (str) => |
|
| 60 | + | str.replace(/</g, '<').replace(/>/g, '>'); |
|
| 61 | + | ||
| 62 | + | // Highlight all matching elements. |
|
| 63 | + | for (const node of document.querySelectorAll(selector)) { |
|
| 64 | + | for (const child of node.childNodes) { |
|
| 65 | + | if (child.nodeType !== Node.TEXT_NODE) continue; |
|
| 66 | + | if (/^\$\s/.test(child.nodeValue.trim())) continue; // Skip shell snippets. |
|
| 67 | + | ||
| 68 | + | for (const [cls, re] of syntax) { |
|
| 69 | + | child.nodeValue = child.nodeValue.replace(re, (_, m) => |
|
| 70 | + | '\u00ab' + encode(cls) + '\u00b7' + |
|
| 71 | + | encode(m) + |
|
| 72 | + | '\u00b7' + encode(cls) + '\u00bb' |
|
| 73 | + | ); |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | node.innerHTML = node.innerHTML.replace( |
|
| 77 | + | /\u00ab(.+?)\u00b7(.+?)\u00b7\1\u00bb/g, |
|
| 78 | + | (_, name, value) => { |
|
| 79 | + | value = value.replace(/\u00ab[^\u00b7]+\u00b7|\u00b7[^\u00bb]+\u00bb/g, ''); |
|
| 80 | + | return '<span class="' + decode(name) + '">' + |
|
| 81 | + | escape(decode(value)) + |
|
| 82 | + | '</span>'; |
|
| 83 | + | } |
|
| 84 | + | ); |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | })(window.hiril); |
static/radiant.svg
added
+32 -0
| 1 | + | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
|
| 2 | + | <rect x="1" y="1" width="1" height="1" fill="currentColor"/> |
|
| 3 | + | <rect x="7" y="1" width="1" height="1" fill="currentColor"/> |
|
| 4 | + | <rect x="13" y="1" width="1" height="1" fill="currentColor"/> |
|
| 5 | + | <rect x="2" y="2" width="1" height="1" fill="currentColor"/> |
|
| 6 | + | <rect x="12" y="2" width="1" height="1" fill="currentColor"/> |
|
| 7 | + | <rect x="3" y="3" width="1" height="1" fill="currentColor"/> |
|
| 8 | + | <rect x="7" y="3" width="1" height="1" fill="currentColor"/> |
|
| 9 | + | <rect x="11" y="3" width="1" height="1" fill="currentColor"/> |
|
| 10 | + | <rect x="4" y="4" width="1" height="1" fill="currentColor"/> |
|
| 11 | + | <rect x="10" y="4" width="1" height="1" fill="currentColor"/> |
|
| 12 | + | <rect x="5" y="5" width="1" height="1" fill="currentColor"/> |
|
| 13 | + | <rect x="9" y="5" width="1" height="1" fill="currentColor"/> |
|
| 14 | + | <rect x="0" y="7" width="1" height="1" fill="currentColor"/> |
|
| 15 | + | <rect x="2" y="7" width="1" height="1" fill="currentColor"/> |
|
| 16 | + | <rect x="3" y="7" width="1" height="1" fill="currentColor"/> |
|
| 17 | + | <rect x="11" y="7" width="1" height="1" fill="currentColor"/> |
|
| 18 | + | <rect x="12" y="7" width="1" height="1" fill="currentColor"/> |
|
| 19 | + | <rect x="14" y="7" width="1" height="1" fill="currentColor"/> |
|
| 20 | + | <rect x="5" y="9" width="1" height="1" fill="currentColor"/> |
|
| 21 | + | <rect x="9" y="9" width="1" height="1" fill="currentColor"/> |
|
| 22 | + | <rect x="4" y="10" width="1" height="1" fill="currentColor"/> |
|
| 23 | + | <rect x="10" y="10" width="1" height="1" fill="currentColor"/> |
|
| 24 | + | <rect x="3" y="11" width="1" height="1" fill="currentColor"/> |
|
| 25 | + | <rect x="7" y="11" width="1" height="1" fill="currentColor"/> |
|
| 26 | + | <rect x="11" y="11" width="1" height="1" fill="currentColor"/> |
|
| 27 | + | <rect x="2" y="12" width="1" height="1" fill="currentColor"/> |
|
| 28 | + | <rect x="12" y="12" width="1" height="1" fill="currentColor"/> |
|
| 29 | + | <rect x="1" y="13" width="1" height="1" fill="currentColor"/> |
|
| 30 | + | <rect x="7" y="13" width="1" height="1" fill="currentColor"/> |
|
| 31 | + | <rect x="13" y="13" width="1" height="1" fill="currentColor"/> |
|
| 32 | + | </svg> |
static/style.css
added
+672 -0
| 1 | + | @font-face { |
|
| 2 | + | font-family: "RethinkSans"; |
|
| 3 | + | font-style: normal; |
|
| 4 | + | font-weight: 400 800; |
|
| 5 | + | font-display: swap; |
|
| 6 | + | font-optical-sizing: auto; |
|
| 7 | + | src: url(/fonts/RethinkSans.ttf); |
|
| 8 | + | } |
|
| 9 | + | @font-face { |
|
| 10 | + | font-family: "RethinkSans"; |
|
| 11 | + | font-style: italic; |
|
| 12 | + | font-weight: 400 800; |
|
| 13 | + | font-display: swap; |
|
| 14 | + | font-optical-sizing: auto; |
|
| 15 | + | src: url(/fonts/RethinkSans-Italic.ttf); |
|
| 16 | + | } |
|
| 17 | + | @font-face { |
|
| 18 | + | font-family: "IBM Plex Mono"; |
|
| 19 | + | font-style: normal; |
|
| 20 | + | font-weight: 400; |
|
| 21 | + | font-display: swap; |
|
| 22 | + | src: url(/fonts/IBMPlexMono-Regular.ttf) format('truetype'); |
|
| 23 | + | } |
|
| 24 | + | @font-face { |
|
| 25 | + | font-family: "IBM Plex Mono"; |
|
| 26 | + | font-style: normal; |
|
| 27 | + | font-weight: 600; |
|
| 28 | + | font-display: swap; |
|
| 29 | + | src: url(/fonts/IBMPlexMono-SemiBold.ttf) format('truetype'); |
|
| 30 | + | } |
|
| 31 | + | @font-face { |
|
| 32 | + | font-family: "IBM Plex Mono"; |
|
| 33 | + | font-style: italic; |
|
| 34 | + | font-weight: 400; |
|
| 35 | + | font-display: swap; |
|
| 36 | + | src: url(/fonts/IBMPlexMono-Italic.ttf) format('truetype'); |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | :root { |
|
| 40 | + | --fg: #112; |
|
| 41 | + | --fg-dim: #666; |
|
| 42 | + | --fg-headers: #222; |
|
| 43 | + | --bg: #d3d1d1; |
|
| 44 | + | --bg-alt: #dedddd; |
|
| 45 | + | --border: #888; |
|
| 46 | + | --link: #223377; |
|
| 47 | + | --link-dim: #334477; |
|
| 48 | + | --add-bg: #cdd6cd; |
|
| 49 | + | --add-fg: #2e7d32; |
|
| 50 | + | --del-bg: #d9cdcd; |
|
| 51 | + | --del-fg: #c62828; |
|
| 52 | + | --color-dim: #334; |
|
| 53 | + | --color-dimmer: #666; |
|
| 54 | + | --color-secondary: #225533; |
|
| 55 | + | --color-highlight: #223377; |
|
| 56 | + | --color-highlight-dim: #334477; |
|
| 57 | + | --mono: "IBM Plex Mono", monospace; |
|
| 58 | + | --sans: "RethinkSans", sans-serif; |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | * { margin: 0; padding: 0; box-sizing: border-box; } |
|
| 62 | + | ||
| 63 | + | body { |
|
| 64 | + | font-family: var(--sans); |
|
| 65 | + | font-size: 16px; |
|
| 66 | + | line-height: 1.4; |
|
| 67 | + | color: var(--fg); |
|
| 68 | + | background: var(--bg); |
|
| 69 | + | -webkit-font-smoothing: antialiased; |
|
| 70 | + | -moz-osx-font-smoothing: grayscale; |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | ::selection { |
|
| 74 | + | color: var(--bg); |
|
| 75 | + | background: var(--link); |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | .container { |
|
| 79 | + | margin: 0 auto; |
|
| 80 | + | padding: 0 1rem 2rem; |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | code, pre, kbd { font-family: var(--mono); font-size: 0.875rem; } |
|
| 84 | + | ||
| 85 | + | a { color: var(--link); text-decoration: none; } |
|
| 86 | + | a:hover { text-decoration: underline; } |
|
| 87 | + | ||
| 88 | + | /* --- Nav --- */ |
|
| 89 | + | ||
| 90 | + | .repo-nav { |
|
| 91 | + | border-bottom: 1px solid var(--border); |
|
| 92 | + | margin: 0 0 1rem; |
|
| 93 | + | padding: 0.25rem 0 0; |
|
| 94 | + | display: flex; |
|
| 95 | + | align-items: flex-end; |
|
| 96 | + | } |
|
| 97 | + | .repo-name { |
|
| 98 | + | display: flex; |
|
| 99 | + | align-items: baseline; |
|
| 100 | + | gap: 0.75rem; |
|
| 101 | + | font-family: var(--sans); |
|
| 102 | + | font-weight: 800; |
|
| 103 | + | font-size: 1.25rem; |
|
| 104 | + | margin-bottom: -1px; |
|
| 105 | + | padding: 0.5rem 0; |
|
| 106 | + | } |
|
| 107 | + | .repo-name a { color: var(--fg); text-decoration: none; } |
|
| 108 | + | .repo-name a:hover { text-decoration: underline; } |
|
| 109 | + | .repo-name .logo-link { text-decoration: none; align-self: center; line-height: 0; } |
|
| 110 | + | .repo-name .logo-link:hover { text-decoration: none; } |
|
| 111 | + | .repo-name .logo { width: 16px; height: 16px; display: block; } |
|
| 112 | + | .repo-desc { |
|
| 113 | + | color: var(--fg-dim); |
|
| 114 | + | font-size: 1rem; |
|
| 115 | + | font-weight: 400; |
|
| 116 | + | } |
|
| 117 | + | .tab { |
|
| 118 | + | display: inline-block; |
|
| 119 | + | padding: 0.5rem 0.75rem; |
|
| 120 | + | color: var(--fg-dim); |
|
| 121 | + | border: 1px solid transparent; |
|
| 122 | + | border-bottom: none; |
|
| 123 | + | margin-bottom: -1px; |
|
| 124 | + | text-decoration: none; |
|
| 125 | + | font-size: 1rem; |
|
| 126 | + | } |
|
| 127 | + | .tab:hover { |
|
| 128 | + | color: var(--fg); |
|
| 129 | + | text-decoration: none; |
|
| 130 | + | } |
|
| 131 | + | .tab.active { |
|
| 132 | + | color: var(--fg); |
|
| 133 | + | border-top: 1px solid var(--border); |
|
| 134 | + | border-left: 1px solid var(--border); |
|
| 135 | + | border-right: 1px solid var(--border); |
|
| 136 | + | border-radius: 4px 4px 0 0; |
|
| 137 | + | background: var(--bg); |
|
| 138 | + | } |
|
| 139 | + | .tab.tab-mono { |
|
| 140 | + | font-family: var(--mono); |
|
| 141 | + | font-size: 1rem; |
|
| 142 | + | } |
|
| 143 | + | .repo-nav .tab:first-of-type { margin-left: auto; } |
|
| 144 | + | ||
| 145 | + | /* --- Headings --- */ |
|
| 146 | + | ||
| 147 | + | h2 { font-family: var(--sans); font-weight: 500; font-size: 1rem; margin-bottom: 0.75rem; color: var(--fg-headers); } |
|
| 148 | + | h3 { |
|
| 149 | + | font-family: var(--sans); |
|
| 150 | + | font-weight: 600; |
|
| 151 | + | font-size: 1rem; |
|
| 152 | + | margin: 2rem 0 0.5rem; |
|
| 153 | + | color: var(--fg); |
|
| 154 | + | } |
|
| 155 | + | h3:first-child { margin-top: 0; } |
|
| 156 | + | ||
| 157 | + | .empty { color: var(--fg-dim); font-style: italic; padding: 0.5rem 0; } |
|
| 158 | + | ||
| 159 | + | /* --- Tables --- */ |
|
| 160 | + | ||
| 161 | + | table { |
|
| 162 | + | width: 100%; |
|
| 163 | + | border-collapse: collapse; |
|
| 164 | + | margin-bottom: 1rem; |
|
| 165 | + | } |
|
| 166 | + | th, td { |
|
| 167 | + | text-align: left; |
|
| 168 | + | padding: 0.25rem 0.75rem; |
|
| 169 | + | border-bottom: 1px solid var(--border); |
|
| 170 | + | } |
|
| 171 | + | th { |
|
| 172 | + | font-size: 1rem; |
|
| 173 | + | padding-top: 0.25rem; |
|
| 174 | + | color: var(--fg-dim); |
|
| 175 | + | font-weight: 600; |
|
| 176 | + | border-bottom: none; |
|
| 177 | + | } |
|
| 178 | + | .repo-list tbody tr:hover, |
|
| 179 | + | .log-list tbody tr:hover, |
|
| 180 | + | .ref-list tbody tr:hover, |
|
| 181 | + | .diffstat-list tbody tr:hover { background: var(--bg-alt); } |
|
| 182 | + | .repo-list td:first-child { font-weight: 600; } |
|
| 183 | + | .repo-list td, |
|
| 184 | + | .log-list td, |
|
| 185 | + | .ref-list td { border-bottom: none; } |
|
| 186 | + | .repo-list th:first-child, |
|
| 187 | + | .repo-list td:first-child, |
|
| 188 | + | .log-list th:first-child, |
|
| 189 | + | .log-list td:first-child, |
|
| 190 | + | .ref-list th:first-child, |
|
| 191 | + | .ref-list td:first-child { padding-left: 0.5rem; } |
|
| 192 | + | .repo-list th:last-child, |
|
| 193 | + | .repo-list td:last-child, |
|
| 194 | + | .log-list th:last-child, |
|
| 195 | + | .log-list td:last-child, |
|
| 196 | + | .ref-list th:last-child, |
|
| 197 | + | .ref-list td:last-child { padding-right: 0.5rem; } |
|
| 198 | + | ||
| 199 | + | .hash { font-family: var(--mono); white-space: nowrap; } |
|
| 200 | + | .date { white-space: nowrap; color: var(--fg-dim); } |
|
| 201 | + | .author { color: var(--fg-dim); white-space: nowrap; } |
|
| 202 | + | .subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
| 203 | + | .mode { font-family: var(--mono); font-size: 1rem; color: var(--fg-dim); white-space: nowrap; } |
|
| 204 | + | .size { font-family: var(--mono); font-size: 0.875rem; color: var(--fg-dim); text-align: right; white-space: nowrap; } |
|
| 205 | + | ||
| 206 | + | .clone-url { |
|
| 207 | + | background: var(--bg-alt); |
|
| 208 | + | border: 1px solid var(--border); |
|
| 209 | + | padding: 0.5rem 0.75rem; |
|
| 210 | + | border-radius: 4px; |
|
| 211 | + | margin-bottom: 1rem; |
|
| 212 | + | font-family: var(--mono); |
|
| 213 | + | font-size: 1rem; |
|
| 214 | + | } |
|
| 215 | + | .clone-url label { font-family: var(--sans); font-weight: bold; margin-right: 0.5rem; } |
|
| 216 | + | ||
| 217 | + | /* --- Breadcrumb --- */ |
|
| 218 | + | ||
| 219 | + | .breadcrumb { |
|
| 220 | + | font-family: var(--mono); |
|
| 221 | + | font-size: 1rem; |
|
| 222 | + | margin-bottom: 1rem; |
|
| 223 | + | color: var(--fg-dim); |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | /* --- File view / blob --- */ |
|
| 227 | + | ||
| 228 | + | .file-view { |
|
| 229 | + | border: 1px solid var(--border); |
|
| 230 | + | border-radius: 4px; |
|
| 231 | + | } |
|
| 232 | + | .file-view .blob-view { border: none; border-radius: 0; } |
|
| 233 | + | .file-header { |
|
| 234 | + | display: flex; |
|
| 235 | + | gap: 0.75rem; |
|
| 236 | + | align-items: baseline; |
|
| 237 | + | padding: 0.5rem 0.75rem; |
|
| 238 | + | border-bottom: 1px solid var(--border); |
|
| 239 | + | font-size: 1rem; |
|
| 240 | + | } |
|
| 241 | + | .file-header .file-name { font-weight: 600; flex: 1; } |
|
| 242 | + | .file-header .file-meta { color: var(--fg-dim); font-size: 0.875rem; } |
|
| 243 | + | .file-header .file-action { |
|
| 244 | + | border-left: 1px solid var(--border); |
|
| 245 | + | padding-left: 0.75rem; |
|
| 246 | + | align-self: stretch; |
|
| 247 | + | margin: -0.5rem 0; |
|
| 248 | + | display: flex; |
|
| 249 | + | align-items: center; |
|
| 250 | + | } |
|
| 251 | + | .file-view pre { |
|
| 252 | + | margin: 0; |
|
| 253 | + | padding: 1rem; |
|
| 254 | + | font-family: var(--mono); |
|
| 255 | + | font-size: 1rem; |
|
| 256 | + | line-height: 1.5; |
|
| 257 | + | white-space: pre-wrap; |
|
| 258 | + | } |
|
| 259 | + | ||
| 260 | + | .blob-view { |
|
| 261 | + | border: 1px solid var(--border); |
|
| 262 | + | border-radius: 4px; |
|
| 263 | + | overflow: auto hidden; |
|
| 264 | + | } |
|
| 265 | + | .blob-code { |
|
| 266 | + | margin: 0; |
|
| 267 | + | border: none; |
|
| 268 | + | border-collapse: collapse; |
|
| 269 | + | border-spacing: 0; |
|
| 270 | + | } |
|
| 271 | + | .blob-code td { border: none; padding: 0; vertical-align: top; line-height: 0; } |
|
| 272 | + | .blob-code tr:first-child td { padding-top: 0.5rem; } |
|
| 273 | + | .blob-code tr:last-child td { padding-bottom: 0.75rem; } |
|
| 274 | + | .blob-code pre { margin: 0; font-family: var(--mono); font-size: 1rem; line-height: 1.45; white-space: pre; display: inline; } |
|
| 275 | + | .line-num { |
|
| 276 | + | width: 1%; |
|
| 277 | + | min-width: 4rem; |
|
| 278 | + | padding: 0 0.5rem; |
|
| 279 | + | text-align: right; |
|
| 280 | + | user-select: none; |
|
| 281 | + | vertical-align: top; |
|
| 282 | + | } |
|
| 283 | + | .line-num a { |
|
| 284 | + | color: var(--fg-dim); |
|
| 285 | + | opacity: 0.4; |
|
| 286 | + | font-family: var(--mono); |
|
| 287 | + | font-size: 1rem; |
|
| 288 | + | line-height: 1.45; |
|
| 289 | + | display: inline-block; |
|
| 290 | + | } |
|
| 291 | + | .line-num a:hover { opacity: 1; text-decoration: none; } |
|
| 292 | + | .line-code { width: 100%; padding-left: 0.75rem; } |
|
| 293 | + | tr:target .line-num { background: var(--bg-alt); } |
|
| 294 | + | tr:target .line-code { background: var(--bg-alt); } |
|
| 295 | + | ||
| 296 | + | /* --- Commit view --- */ |
|
| 297 | + | ||
| 298 | + | .commit-info { margin-bottom: 1.5rem; } |
|
| 299 | + | .commit-title { |
|
| 300 | + | display: flex; |
|
| 301 | + | align-items: baseline; |
|
| 302 | + | gap: 1rem; |
|
| 303 | + | margin-bottom: 0.75rem; |
|
| 304 | + | } |
|
| 305 | + | .commit-title .subject { font-family: var(--sans); font-weight: 600; font-size: 1.125rem; flex: 1; } |
|
| 306 | + | .commit-hash { font-family: var(--mono); font-size: 1rem; color: var(--fg-dim); white-space: nowrap; } |
|
| 307 | + | .commit-info .body { font-family: var(--mono); font-size: 1rem; margin-bottom: 0.75rem; white-space: pre-wrap; color: var(--fg-dim); } |
|
| 308 | + | .commit-subtitle { |
|
| 309 | + | display: flex; |
|
| 310 | + | align-items: baseline; |
|
| 311 | + | font-size: 1rem; |
|
| 312 | + | color: var(--fg-dim); |
|
| 313 | + | } |
|
| 314 | + | .commit-meta { flex: 1; } |
|
| 315 | + | .commit-parents { white-space: nowrap; } |
|
| 316 | + | .commit-parents code { font-family: var(--mono); } |
|
| 317 | + | ||
| 318 | + | /* --- Diffstat --- */ |
|
| 319 | + | ||
| 320 | + | .diffstat { margin-bottom: 1.5rem; } |
|
| 321 | + | .diffstat-summary { |
|
| 322 | + | font-size: 1rem; |
|
| 323 | + | color: var(--fg-dim); |
|
| 324 | + | margin-bottom: 0.75rem; |
|
| 325 | + | } |
|
| 326 | + | .diffstat-list { |
|
| 327 | + | font-size: 1rem; |
|
| 328 | + | width: auto; |
|
| 329 | + | border: 1px solid var(--border); |
|
| 330 | + | border-radius: 4px; |
|
| 331 | + | } |
|
| 332 | + | .diffstat-list td { border: none; padding: 0.25rem 0.75rem; } |
|
| 333 | + | .diffstat-list td:first-child { padding-left: 0.75rem; padding-right: 0.5rem; width: 1%; } |
|
| 334 | + | .diffstat-list .name { font-family: var(--mono); } |
|
| 335 | + | .diffstat-list .stats { |
|
| 336 | + | white-space: nowrap; |
|
| 337 | + | font-family: var(--mono); |
|
| 338 | + | text-align: right; |
|
| 339 | + | width: 1%; |
|
| 340 | + | padding-right: 0.75rem; |
|
| 341 | + | } |
|
| 342 | + | .diffstat-bar { |
|
| 343 | + | display: inline-flex; |
|
| 344 | + | gap: 1px; |
|
| 345 | + | vertical-align: middle; |
|
| 346 | + | margin-left: 0.5rem; |
|
| 347 | + | } |
|
| 348 | + | .diffstat-bar span { |
|
| 349 | + | display: inline-block; |
|
| 350 | + | width: 8px; |
|
| 351 | + | height: 8px; |
|
| 352 | + | border-radius: 1px; |
|
| 353 | + | } |
|
| 354 | + | .diff-header-stats { margin-left: auto; font-family: var(--mono); font-size: 1rem; white-space: nowrap; color: var(--fg-dim); } |
|
| 355 | + | .diffstat-bar .bar-add { background: var(--add-fg); } |
|
| 356 | + | .diffstat-bar .bar-del { background: var(--del-fg); } |
|
| 357 | + | .diffstat-bar .bar-neutral { background: #bbb; } |
|
| 358 | + | ||
| 359 | + | /* --- Status badges --- */ |
|
| 360 | + | ||
| 361 | + | .status { |
|
| 362 | + | font-family: var(--mono); |
|
| 363 | + | font-size: 1rem; |
|
| 364 | + | font-weight: 600; |
|
| 365 | + | display: inline-block; |
|
| 366 | + | padding: 0.125rem 0.5rem; |
|
| 367 | + | border-radius: 4px; |
|
| 368 | + | letter-spacing: 0.03em; |
|
| 369 | + | border: 1px solid; |
|
| 370 | + | } |
|
| 371 | + | .status-A { color: var(--add-fg); border-color: var(--add-fg); } |
|
| 372 | + | .status-D { color: var(--del-fg); border-color: var(--del-fg); } |
|
| 373 | + | .status-M { color: #b08800; border-color: #b08800; } |
|
| 374 | + | .status-R { color: #6f42c1; border-color: #6f42c1; } |
|
| 375 | + | ||
| 376 | + | .add { color: var(--add-fg); } |
|
| 377 | + | .del { color: var(--del-fg); } |
|
| 378 | + | ||
| 379 | + | /* --- Diff files --- */ |
|
| 380 | + | ||
| 381 | + | .diff-file { |
|
| 382 | + | border: 1px solid var(--border); |
|
| 383 | + | border-radius: 4px; |
|
| 384 | + | margin-bottom: 1rem; |
|
| 385 | + | overflow-x: auto; |
|
| 386 | + | } |
|
| 387 | + | .diff-header { |
|
| 388 | + | display: flex; |
|
| 389 | + | align-items: center; |
|
| 390 | + | gap: 0.5rem; |
|
| 391 | + | background: none; |
|
| 392 | + | border-bottom: 1px solid var(--border); |
|
| 393 | + | padding: 0.5rem 0.75rem; |
|
| 394 | + | font-size: 1rem; |
|
| 395 | + | font-weight: 500; |
|
| 396 | + | color: var(--fg-headers); |
|
| 397 | + | } |
|
| 398 | + | .diff-header .status { font-weight: 600; font-size: 0.875rem; } |
|
| 399 | + | .diff-hunk { |
|
| 400 | + | margin: 0; |
|
| 401 | + | border: none; |
|
| 402 | + | width: 100%; |
|
| 403 | + | } |
|
| 404 | + | .diff-hunk td { border: none; padding: 0; vertical-align: top; } |
|
| 405 | + | td.diff-num { |
|
| 406 | + | width: 1%; |
|
| 407 | + | min-width: 3rem; |
|
| 408 | + | padding: 0 0.25rem 0 1rem; |
|
| 409 | + | text-align: right; |
|
| 410 | + | font-family: var(--mono); |
|
| 411 | + | font-size: 1rem; |
|
| 412 | + | line-height: 1.45; |
|
| 413 | + | color: var(--fg-dim); |
|
| 414 | + | opacity: 0.5; |
|
| 415 | + | user-select: none; |
|
| 416 | + | } |
|
| 417 | + | td.diff-marker { |
|
| 418 | + | padding: 0 0.25rem 0 1rem; |
|
| 419 | + | font-family: var(--mono); |
|
| 420 | + | font-size: 1rem; |
|
| 421 | + | line-height: 1.45; |
|
| 422 | + | user-select: none; |
|
| 423 | + | } |
|
| 424 | + | .diff-add .diff-num, |
|
| 425 | + | .diff-add .diff-marker { color: var(--add-fg); opacity: 0.7; } |
|
| 426 | + | .diff-del .diff-num, |
|
| 427 | + | .diff-del .diff-marker { color: var(--del-fg); opacity: 0.7; } |
|
| 428 | + | .diff-context .diff-marker { color: transparent; } |
|
| 429 | + | .diff-hunk pre { margin: 0; font-family: var(--mono); font-size: 1rem; line-height: 1.45; white-space: pre; padding: 0 0.75rem; } |
|
| 430 | + | .diff-hunk tr:first-child td { padding-top: 0.5rem; } |
|
| 431 | + | .diff-hunk tr:last-child td { padding-bottom: 0.5rem; } |
|
| 432 | + | .diff-add td { background: var(--add-bg); } |
|
| 433 | + | .diff-del td { background: var(--del-bg); } |
|
| 434 | + | .diff-sep { |
|
| 435 | + | margin: 0.25rem 0; |
|
| 436 | + | border-top: 1px dashed var(--border); |
|
| 437 | + | } |
|
| 438 | + | ||
| 439 | + | /* --- Pagination --- */ |
|
| 440 | + | ||
| 441 | + | .pagination { |
|
| 442 | + | display: flex; |
|
| 443 | + | justify-content: center; |
|
| 444 | + | padding: 0.75rem 0 0.25rem; |
|
| 445 | + | font-size: 1rem; |
|
| 446 | + | } |
|
| 447 | + | ||
| 448 | + | .pagination a { |
|
| 449 | + | padding: 0.35rem 0.75rem; |
|
| 450 | + | border: 1px solid var(--border); |
|
| 451 | + | color: var(--fg-dim); |
|
| 452 | + | text-decoration: none; |
|
| 453 | + | } |
|
| 454 | + | ||
| 455 | + | .pagination a + a { |
|
| 456 | + | border-left: none; |
|
| 457 | + | } |
|
| 458 | + | ||
| 459 | + | .pagination a:first-child { |
|
| 460 | + | border-radius: 4px 0 0 4px; |
|
| 461 | + | } |
|
| 462 | + | ||
| 463 | + | .pagination a:last-child { |
|
| 464 | + | border-radius: 0 4px 4px 0; |
|
| 465 | + | } |
|
| 466 | + | ||
| 467 | + | .pagination a:only-child { |
|
| 468 | + | border-radius: 4px; |
|
| 469 | + | } |
|
| 470 | + | ||
| 471 | + | .pagination a:hover { |
|
| 472 | + | color: var(--fg); |
|
| 473 | + | } |
|
| 474 | + | ||
| 475 | + | /* --- Last commit bar --- */ |
|
| 476 | + | ||
| 477 | + | .last-commit { |
|
| 478 | + | display: flex; |
|
| 479 | + | align-items: center; |
|
| 480 | + | gap: 0.75rem; |
|
| 481 | + | border: 1px solid var(--border); |
|
| 482 | + | border-radius: 4px; |
|
| 483 | + | padding: 0.25rem 0.75rem; |
|
| 484 | + | font-size: 0.875rem; |
|
| 485 | + | overflow: hidden; |
|
| 486 | + | } |
|
| 487 | + | .last-commit .date { |
|
| 488 | + | margin-left: -0.5rem; |
|
| 489 | + | } |
|
| 490 | + | ||
| 491 | + | /* --- Repo home (tree + content) --- */ |
|
| 492 | + | ||
| 493 | + | .repo-home { |
|
| 494 | + | display: grid; |
|
| 495 | + | grid-template-columns: 280px 1fr; |
|
| 496 | + | gap: 1.5rem; |
|
| 497 | + | align-items: start; |
|
| 498 | + | } |
|
| 499 | + | /* --- Branch header --- */ |
|
| 500 | + | ||
| 501 | + | .branch-header { |
|
| 502 | + | display: flex; |
|
| 503 | + | align-items: stretch; |
|
| 504 | + | margin-left: auto; |
|
| 505 | + | margin-bottom: 0.25rem; |
|
| 506 | + | } |
|
| 507 | + | .branch-header .branch-selector summary { |
|
| 508 | + | border-radius: 4px 0 0 4px; |
|
| 509 | + | border-right: none; |
|
| 510 | + | } |
|
| 511 | + | .branch-header .last-commit { |
|
| 512 | + | border-radius: 0 4px 4px 0; |
|
| 513 | + | } |
|
| 514 | + | ||
| 515 | + | /* --- View nav (Files / Log) --- */ |
|
| 516 | + | ||
| 517 | + | .view-nav { |
|
| 518 | + | margin: 0 0 1rem; |
|
| 519 | + | padding: 0.5rem 0; |
|
| 520 | + | display: flex; |
|
| 521 | + | align-items: center; |
|
| 522 | + | } |
|
| 523 | + | .view-nav .last-commit { |
|
| 524 | + | margin-left: auto; |
|
| 525 | + | } |
|
| 526 | + | .view-nav .branch-selector { |
|
| 527 | + | margin-right: 1.5rem; |
|
| 528 | + | } |
|
| 529 | + | ||
| 530 | + | /* --- Branch selector --- */ |
|
| 531 | + | ||
| 532 | + | .branch-selector { |
|
| 533 | + | position: relative; |
|
| 534 | + | } |
|
| 535 | + | .branch-selector summary { |
|
| 536 | + | display: flex; |
|
| 537 | + | align-items: center; |
|
| 538 | + | gap: 0.5rem; |
|
| 539 | + | border: 1px solid var(--border); |
|
| 540 | + | border-radius: 4px; |
|
| 541 | + | padding: 0.25rem 0.75rem; |
|
| 542 | + | font-size: 0.875rem; |
|
| 543 | + | font-family: var(--mono); |
|
| 544 | + | cursor: pointer; |
|
| 545 | + | list-style: none; |
|
| 546 | + | } |
|
| 547 | + | .branch-selector summary:hover { |
|
| 548 | + | background: var(--bg-alt); |
|
| 549 | + | } |
|
| 550 | + | .branch-selector summary::-webkit-details-marker { display: none; } |
|
| 551 | + | .branch-selector summary::after { |
|
| 552 | + | content: ""; |
|
| 553 | + | flex-shrink: 0; |
|
| 554 | + | margin-left: auto; |
|
| 555 | + | width: 0; |
|
| 556 | + | height: 0; |
|
| 557 | + | border-left: 4px solid transparent; |
|
| 558 | + | border-right: 4px solid transparent; |
|
| 559 | + | border-top: 5px solid var(--fg-dim); |
|
| 560 | + | } |
|
| 561 | + | .branch-selector[open] summary::after { |
|
| 562 | + | border-top: none; |
|
| 563 | + | border-bottom: 5px solid var(--fg-dim); |
|
| 564 | + | } |
|
| 565 | + | .branch-selector ul { |
|
| 566 | + | position: absolute; |
|
| 567 | + | z-index: 10; |
|
| 568 | + | list-style: none; |
|
| 569 | + | background: var(--bg); |
|
| 570 | + | border: 1px solid var(--border); |
|
| 571 | + | border-radius: 4px; |
|
| 572 | + | margin-top: 0.25rem; |
|
| 573 | + | padding: 0.25rem 0; |
|
| 574 | + | min-width: 100%; |
|
| 575 | + | max-height: 300px; |
|
| 576 | + | overflow-y: auto; |
|
| 577 | + | } |
|
| 578 | + | .branch-selector li a { |
|
| 579 | + | display: block; |
|
| 580 | + | padding: 0.25rem 0.75rem; |
|
| 581 | + | font-size: 0.875rem; |
|
| 582 | + | font-family: var(--mono); |
|
| 583 | + | text-decoration: none; |
|
| 584 | + | white-space: nowrap; |
|
| 585 | + | } |
|
| 586 | + | .branch-selector li a:hover { |
|
| 587 | + | background: var(--bg-alt); |
|
| 588 | + | text-decoration: none; |
|
| 589 | + | } |
|
| 590 | + | .branch-selector li.active a { |
|
| 591 | + | background: var(--bg-alt); |
|
| 592 | + | font-weight: 600; |
|
| 593 | + | } |
|
| 594 | + | ||
| 595 | + | /* --- File tree --- */ |
|
| 596 | + | ||
| 597 | + | .tree-entry { |
|
| 598 | + | display: flex; |
|
| 599 | + | align-items: baseline; |
|
| 600 | + | padding: 0.25rem 0.5rem; |
|
| 601 | + | border-radius: 4px; |
|
| 602 | + | color: var(--link); |
|
| 603 | + | text-decoration: none; |
|
| 604 | + | } |
|
| 605 | + | .tree-entry:hover { text-decoration: none; background: var(--bg-alt); } |
|
| 606 | + | .tree-entry.active { background: var(--bg-alt); } |
|
| 607 | + | .tree-entry.active .name { font-weight: 600; } |
|
| 608 | + | .file-tree .name { white-space: nowrap; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } |
|
| 609 | + | .file-tree .size { white-space: nowrap; margin-left: 0.5rem; } |
|
| 610 | + | ||
| 611 | + | .content-view { min-width: 0; } |
|
| 612 | + | .content-view h2 { font-size: 1rem; margin-bottom: 0.5rem; } |
|
| 613 | + | ||
| 614 | + | /* --- Error page --- */ |
|
| 615 | + | ||
| 616 | + | .error-page { |
|
| 617 | + | text-align: center; |
|
| 618 | + | padding: 4rem 1rem; |
|
| 619 | + | } |
|
| 620 | + | .error-code { |
|
| 621 | + | font-family: var(--mono); |
|
| 622 | + | font-size: 1.125rem; |
|
| 623 | + | font-weight: 600; |
|
| 624 | + | color: var(--fg-dim); |
|
| 625 | + | display: block; |
|
| 626 | + | margin-bottom: 0.5rem; |
|
| 627 | + | } |
|
| 628 | + | .error-message { |
|
| 629 | + | color: var(--fg); |
|
| 630 | + | margin-bottom: 0.5rem; |
|
| 631 | + | } |
|
| 632 | + | .error-path { |
|
| 633 | + | display: block; |
|
| 634 | + | color: var(--fg-dim); |
|
| 635 | + | margin-bottom: 1.5rem; |
|
| 636 | + | } |
|
| 637 | + | ||
| 638 | + | /* --- Footer --- */ |
|
| 639 | + | ||
| 640 | + | footer { |
|
| 641 | + | margin-top: 1.5rem; |
|
| 642 | + | padding: 0.75rem 0 1.5rem; |
|
| 643 | + | border-top: 1px solid var(--border); |
|
| 644 | + | font-size: 1rem; |
|
| 645 | + | color: var(--fg-dim); |
|
| 646 | + | } |
|
| 647 | + | ||
| 648 | + | /* --- Syntax highlighting (Radiance) --- */ |
|
| 649 | + | ||
| 650 | + | .language-radiance .comment { color: var(--color-dimmer); font-style: italic; } |
|
| 651 | + | .language-radiance .builtin { color: var(--color-secondary); } |
|
| 652 | + | .language-radiance .op { color: var(--color-dim); } |
|
| 653 | + | .language-radiance .access, |
|
| 654 | + | .language-radiance .delim { color: var(--color-dim); } |
|
| 655 | + | .language-radiance .keyword { color: var(--color-dim); font-weight: 600; } |
|
| 656 | + | .language-radiance .type { color: var(--color-highlight-dim); font-weight: 600; } |
|
| 657 | + | .language-radiance .ref { color: var(--color-dim); } |
|
| 658 | + | .language-radiance .string { color: var(--color-secondary); } |
|
| 659 | + | .language-radiance .number { color: var(--color-highlight); } |
|
| 660 | + | ||
| 661 | + | /* --- Syntax highlighting (Radiance IL) --- */ |
|
| 662 | + | ||
| 663 | + | .language-ril .comment { color: var(--color-dimmer); font-style: italic; } |
|
| 664 | + | .language-ril .keyword, |
|
| 665 | + | .language-ril .instr { color: var(--color-dim); font-weight: 600; } |
|
| 666 | + | .language-ril .type { color: var(--color-highlight-dim); font-weight: 600; } |
|
| 667 | + | .language-ril .delim { color: var(--color-dim); } |
|
| 668 | + | .language-ril .number { color: var(--color-highlight); } |
|
| 669 | + | .language-ril .string { color: var(--color-secondary); } |
|
| 670 | + | .language-ril .reg { color: var(--color-secondary); } |
|
| 671 | + | .language-ril .symbol { color: var(--color-highlight-dim); } |
|
| 672 | + | .language-ril .label { color: var(--color-highlight-dim); font-style: italic; } |
template.go
added
+220 -0
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "embed" |
|
| 5 | + | "fmt" |
|
| 6 | + | "html/template" |
|
| 7 | + | "io" |
|
| 8 | + | "path" |
|
| 9 | + | "strings" |
|
| 10 | + | "time" |
|
| 11 | + | ) |
|
| 12 | + | ||
| 13 | + | //go:embed templates/*.html |
|
| 14 | + | var templateFS embed.FS |
|
| 15 | + | ||
| 16 | + | //go:embed static/style.css |
|
| 17 | + | var cssContent []byte |
|
| 18 | + | ||
| 19 | + | //go:embed static/radiant.svg |
|
| 20 | + | var logoContent []byte |
|
| 21 | + | ||
| 22 | + | //go:embed static/fonts/* |
|
| 23 | + | var fontsFS embed.FS |
|
| 24 | + | ||
| 25 | + | //go:embed static/js/hirad.js |
|
| 26 | + | var hiradJS []byte |
|
| 27 | + | ||
| 28 | + | //go:embed static/js/hiril.js |
|
| 29 | + | var hirilJS []byte |
|
| 30 | + | ||
| 31 | + | type templateSet struct { |
|
| 32 | + | templates map[string]*template.Template |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | var funcMap = template.FuncMap{ |
|
| 36 | + | "shortHash": func(h string) string { |
|
| 37 | + | if len(h) > 8 { |
|
| 38 | + | return h[:8] |
|
| 39 | + | } |
|
| 40 | + | return h |
|
| 41 | + | }, |
|
| 42 | + | "timeAgo": func(t time.Time) template.HTML { |
|
| 43 | + | d := time.Since(t) |
|
| 44 | + | var ago string |
|
| 45 | + | switch { |
|
| 46 | + | case d < time.Minute: |
|
| 47 | + | ago = "just now" |
|
| 48 | + | case d < time.Hour: |
|
| 49 | + | m := int(d.Minutes()) |
|
| 50 | + | if m == 1 { |
|
| 51 | + | ago = "1 minute" |
|
| 52 | + | } else { |
|
| 53 | + | ago = fmt.Sprintf("%d minutes", m) |
|
| 54 | + | } |
|
| 55 | + | case d < 24*time.Hour: |
|
| 56 | + | h := int(d.Hours()) |
|
| 57 | + | if h == 1 { |
|
| 58 | + | ago = "1 hour" |
|
| 59 | + | } else { |
|
| 60 | + | ago = fmt.Sprintf("%d hours", h) |
|
| 61 | + | } |
|
| 62 | + | case d < 30*24*time.Hour: |
|
| 63 | + | days := int(d.Hours() / 24) |
|
| 64 | + | if days == 1 { |
|
| 65 | + | ago = "1 day" |
|
| 66 | + | } else { |
|
| 67 | + | ago = fmt.Sprintf("%d days", days) |
|
| 68 | + | } |
|
| 69 | + | case d < 365*24*time.Hour: |
|
| 70 | + | months := int(d.Hours() / 24 / 30) |
|
| 71 | + | if months == 1 { |
|
| 72 | + | ago = "1 month" |
|
| 73 | + | } else { |
|
| 74 | + | ago = fmt.Sprintf("%d months", months) |
|
| 75 | + | } |
|
| 76 | + | default: |
|
| 77 | + | years := int(d.Hours() / 24 / 365) |
|
| 78 | + | if years == 1 { |
|
| 79 | + | ago = "1 year" |
|
| 80 | + | } else { |
|
| 81 | + | ago = fmt.Sprintf("%d years", years) |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | full := t.Format("2006-01-02 15:04:05 -0700") |
|
| 85 | + | return template.HTML(fmt.Sprintf(`<time title="%s">%s</time>`, full, ago)) |
|
| 86 | + | }, |
|
| 87 | + | "formatDate": func(t time.Time) string { |
|
| 88 | + | return t.Format("2006-01-02 15:04:05 -0700") |
|
| 89 | + | }, |
|
| 90 | + | "add": func(a, b int) int { return a + b }, |
|
| 91 | + | "diffFileName": func(f DiffFile) string { |
|
| 92 | + | if f.NewName != "" { |
|
| 93 | + | return f.NewName |
|
| 94 | + | } |
|
| 95 | + | return f.OldName |
|
| 96 | + | }, |
|
| 97 | + | "statusLabel": func(s string) string { |
|
| 98 | + | switch s { |
|
| 99 | + | case "A": |
|
| 100 | + | return "added" |
|
| 101 | + | case "D": |
|
| 102 | + | return "deleted" |
|
| 103 | + | case "M": |
|
| 104 | + | return "modified" |
|
| 105 | + | case "R": |
|
| 106 | + | return "renamed" |
|
| 107 | + | } |
|
| 108 | + | return s |
|
| 109 | + | }, |
|
| 110 | + | "formatSize": func(size int64) string { |
|
| 111 | + | switch { |
|
| 112 | + | case size < 1024: |
|
| 113 | + | return fmt.Sprintf("%d B", size) |
|
| 114 | + | case size < 1024*1024: |
|
| 115 | + | return fmt.Sprintf("%.1f KiB", float64(size)/1024) |
|
| 116 | + | default: |
|
| 117 | + | return fmt.Sprintf("%.1f MiB", float64(size)/(1024*1024)) |
|
| 118 | + | } |
|
| 119 | + | }, |
|
| 120 | + | "diffBar": func(added, deleted int) template.HTML { |
|
| 121 | + | total := added + deleted |
|
| 122 | + | if total == 0 { |
|
| 123 | + | return "" |
|
| 124 | + | } |
|
| 125 | + | const maxBlocks = 5 |
|
| 126 | + | blocks := maxBlocks |
|
| 127 | + | if total < blocks { |
|
| 128 | + | blocks = total |
|
| 129 | + | } |
|
| 130 | + | addBlocks := 0 |
|
| 131 | + | if total > 0 { |
|
| 132 | + | addBlocks = (added * blocks + total - 1) / total |
|
| 133 | + | } |
|
| 134 | + | if addBlocks > blocks { |
|
| 135 | + | addBlocks = blocks |
|
| 136 | + | } |
|
| 137 | + | delBlocks := blocks - addBlocks |
|
| 138 | + | var b strings.Builder |
|
| 139 | + | b.WriteString(`<span class="diffstat-bar">`) |
|
| 140 | + | for i := 0; i < addBlocks; i++ { |
|
| 141 | + | b.WriteString(`<span class="bar-add"></span>`) |
|
| 142 | + | } |
|
| 143 | + | for i := 0; i < delBlocks; i++ { |
|
| 144 | + | b.WriteString(`<span class="bar-del"></span>`) |
|
| 145 | + | } |
|
| 146 | + | for i := addBlocks + delBlocks; i < maxBlocks; i++ { |
|
| 147 | + | b.WriteString(`<span class="bar-neutral"></span>`) |
|
| 148 | + | } |
|
| 149 | + | b.WriteString(`</span>`) |
|
| 150 | + | return template.HTML(b.String()) |
|
| 151 | + | }, |
|
| 152 | + | "langClass": func(name string) string { |
|
| 153 | + | ext := path.Ext(name) |
|
| 154 | + | switch ext { |
|
| 155 | + | case ".rad": |
|
| 156 | + | return "language-radiance" |
|
| 157 | + | case ".ril": |
|
| 158 | + | return "language-ril" |
|
| 159 | + | } |
|
| 160 | + | return "" |
|
| 161 | + | }, |
|
| 162 | + | "parentPath": func(p string) string { |
|
| 163 | + | dir := path.Dir(p) |
|
| 164 | + | if dir == "." { |
|
| 165 | + | return "" |
|
| 166 | + | } |
|
| 167 | + | return dir |
|
| 168 | + | }, |
|
| 169 | + | "indent": func(depth int) string { |
|
| 170 | + | if depth == 0 { |
|
| 171 | + | return "" |
|
| 172 | + | } |
|
| 173 | + | return fmt.Sprintf("padding-left: %grem", float64(depth)*1.2) |
|
| 174 | + | }, |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | func loadTemplates() (*templateSet, error) { |
|
| 178 | + | layoutContent, err := templateFS.ReadFile("templates/layout.html") |
|
| 179 | + | if err != nil { |
|
| 180 | + | return nil, fmt.Errorf("read layout: %w", err) |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | pages := []string{"index", "home", "log", "commit", "refs", "error"} |
|
| 184 | + | ts := &templateSet{ |
|
| 185 | + | templates: make(map[string]*template.Template, len(pages)), |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | for _, page := range pages { |
|
| 189 | + | t := template.New("layout").Funcs(funcMap) |
|
| 190 | + | t, err = t.Parse(string(layoutContent)) |
|
| 191 | + | if err != nil { |
|
| 192 | + | return nil, fmt.Errorf("parse layout: %w", err) |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | pageContent, err := templateFS.ReadFile(fmt.Sprintf("templates/%s.html", page)) |
|
| 196 | + | if err != nil { |
|
| 197 | + | return nil, fmt.Errorf("read %s: %w", page, err) |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | t, err = t.Parse(string(pageContent)) |
|
| 201 | + | if err != nil { |
|
| 202 | + | return nil, fmt.Errorf("parse %s: %w", page, err) |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | ts.templates[page] = t |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | return ts, nil |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | func (ts *templateSet) render(w io.Writer, name string, data any) { |
|
| 212 | + | t, ok := ts.templates[name] |
|
| 213 | + | if !ok { |
|
| 214 | + | fmt.Fprintf(w, "template %q not found", name) |
|
| 215 | + | return |
|
| 216 | + | } |
|
| 217 | + | if err := t.Execute(w, data); err != nil { |
|
| 218 | + | fmt.Fprintf(w, "template error: %v", err) |
|
| 219 | + | } |
|
| 220 | + | } |
templates/commit.html
added
+46 -0
| 1 | + | {{define "content"}} |
|
| 2 | + | <div class="commit-info"> |
|
| 3 | + | <div class="commit-title"> |
|
| 4 | + | <p class="subject">{{.Data.Commit.Subject}}</p> |
|
| 5 | + | <code class="commit-hash">{{.Data.Commit.Hash}}</code> |
|
| 6 | + | </div> |
|
| 7 | + | {{if .Data.Commit.Body}}<pre class="body">{{.Data.Commit.Body}}</pre>{{end}} |
|
| 8 | + | <div class="commit-subtitle"> |
|
| 9 | + | <span class="commit-meta">{{.Data.Commit.Author}} committed {{timeAgo .Data.Commit.AuthorDate}} ago</span> |
|
| 10 | + | {{if .Data.Commit.Parents}}<span class="commit-parents">{{len .Data.Commit.Parents}} parent{{if ne (len .Data.Commit.Parents) 1}}s{{end}} {{range $i, $p := .Data.Commit.Parents}}{{if $i}} {{end}}<a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{$p}}"><code>{{shortHash $p}}</code></a>{{end}}</span>{{end}} |
|
| 11 | + | </div> |
|
| 12 | + | </div> |
|
| 13 | + | ||
| 14 | + | {{range $file := .Data.Files}} |
|
| 15 | + | <div class="diff-file"> |
|
| 16 | + | <div class="diff-header"> |
|
| 17 | + | {{if eq .Status "R"}} |
|
| 18 | + | <span>{{.OldName}} → {{.NewName}}</span> |
|
| 19 | + | {{else}} |
|
| 20 | + | <span>{{diffFileName .}}</span> |
|
| 21 | + | {{end}} |
|
| 22 | + | {{if ne .Status "M"}}<span class="status status-{{.Status}}">{{statusLabel .Status}}</span>{{end}} |
|
| 23 | + | <span class="diff-header-stats"><span class="add">+{{.Stats.Added}}</span> <span class="del">-{{.Stats.Deleted}}</span> {{diffBar .Stats.Added .Stats.Deleted}}</span> |
|
| 24 | + | </div> |
|
| 25 | + | {{if .IsBinary}} |
|
| 26 | + | <p class="empty">Binary file changed.</p> |
|
| 27 | + | {{else}} |
|
| 28 | + | {{range $i, $hunk := .Hunks}} |
|
| 29 | + | {{if $i}}<div class="diff-sep"></div>{{end}} |
|
| 30 | + | <table class="diff-hunk"> |
|
| 31 | + | <tbody> |
|
| 32 | + | {{range .Lines}} |
|
| 33 | + | <tr class="diff-line diff-{{.Type}}"> |
|
| 34 | + | <td class="diff-num">{{if .OldNum}}{{.OldNum}}{{end}}</td> |
|
| 35 | + | <td class="diff-num">{{if .NewNum}}{{.NewNum}}{{end}}</td> |
|
| 36 | + | <td class="diff-marker">{{if eq .Type "add"}}+{{else if eq .Type "del"}}-{{else}} {{end}}</td> |
|
| 37 | + | <td class="line-code"><pre{{with langClass (diffFileName $file)}} class="{{.}}"{{end}}>{{.Content}}</pre></td> |
|
| 38 | + | </tr> |
|
| 39 | + | {{end}} |
|
| 40 | + | </tbody> |
|
| 41 | + | </table> |
|
| 42 | + | {{end}} |
|
| 43 | + | {{end}} |
|
| 44 | + | </div> |
|
| 45 | + | {{end}} |
|
| 46 | + | {{end}} |
templates/error.html
added
+8 -0
| 1 | + | {{define "content"}} |
|
| 2 | + | <div class="error-page"> |
|
| 3 | + | <span class="error-code">{{.Data.Code}}</span> |
|
| 4 | + | <p class="error-message">{{.Data.Message}}</p> |
|
| 5 | + | <code class="error-path">{{.Data.Path}}</code> |
|
| 6 | + | <p><a href="{{.BaseURL}}/">← Back to index</a></p> |
|
| 7 | + | </div> |
|
| 8 | + | {{end}} |
templates/home.html
added
+72 -0
| 1 | + | {{define "content"}} |
|
| 2 | + | {{if .Data.IsEmpty}} |
|
| 3 | + | <p class="empty">This repository is empty.</p> |
|
| 4 | + | {{else}} |
|
| 5 | + | ||
| 6 | + | <nav class="view-nav"> |
|
| 7 | + | <details class="branch-selector"> |
|
| 8 | + | <summary>{{.Data.DefaultRef}}</summary> |
|
| 9 | + | <ul> |
|
| 10 | + | {{range .Data.Branches}} |
|
| 11 | + | <li{{if eq .Name $.Data.DefaultRef}} class="active"{{end}}><a href="{{$.BaseURL}}/{{$.Repo}}/tree/{{.Name}}/">{{.Name}}</a></li> |
|
| 12 | + | {{end}} |
|
| 13 | + | </ul> |
|
| 14 | + | </details> |
|
| 15 | + | {{if .Data.LastCommit}} |
|
| 16 | + | <div class="last-commit"> |
|
| 17 | + | <span class="hash"><a href="{{.BaseURL}}/{{.Repo}}/commit/{{.Data.LastCommit.Hash}}">{{.Data.LastCommit.ShortHash}}</a></span> |
|
| 18 | + | <span class="subject">{{.Data.LastCommit.Subject}}</span> |
|
| 19 | + | <span class="author">{{.Data.LastCommit.Author}}</span> |
|
| 20 | + | <span class="date">{{timeAgo .Data.LastCommit.AuthorDate}} ago</span> |
|
| 21 | + | </div> |
|
| 22 | + | {{end}} |
|
| 23 | + | </nav> |
|
| 24 | + | ||
| 25 | + | <div class="repo-home"> |
|
| 26 | + | <div class="file-tree"> |
|
| 27 | + | {{range .Data.Tree}} |
|
| 28 | + | <a class="tree-entry{{if .IsActive}} active{{end}}" href="{{$.BaseURL}}/{{$.Repo}}/tree/{{$.Data.DefaultRef}}/{{if and .IsDir .IsOpen}}{{parentPath .Path}}{{else}}{{.Path}}{{end}}" style="{{indent .Depth}}"> |
|
| 29 | + | <span class="name">{{.Name}}{{if .IsDir}}/{{end}}</span> |
|
| 30 | + | <span class="size">{{if not .IsDir}}{{formatSize .Size}}{{end}}</span> |
|
| 31 | + | </a> |
|
| 32 | + | {{end}} |
|
| 33 | + | </div> |
|
| 34 | + | ||
| 35 | + | <div class="content-view"> |
|
| 36 | + | {{if .Data.ActiveBlob}} |
|
| 37 | + | <div class="file-view"> |
|
| 38 | + | <div class="file-header"> |
|
| 39 | + | <span class="file-name">{{.Data.ActivePath}}</span> |
|
| 40 | + | <span class="file-meta">{{formatSize .Data.ActiveBlob.Size}}</span> |
|
| 41 | + | <a class="file-action" href="{{.BaseURL}}/{{.Repo}}/raw/{{.Data.DefaultRef}}/{{.Data.ActivePath}}">raw</a> |
|
| 42 | + | </div> |
|
| 43 | + | {{if .Data.ActiveBlob.IsBinary}} |
|
| 44 | + | <p class="empty">Binary file. <a href="{{.BaseURL}}/{{.Repo}}/raw/{{.Data.DefaultRef}}/{{.Data.ActivePath}}">Download</a></p> |
|
| 45 | + | {{else}} |
|
| 46 | + | <div class="blob-view"> |
|
| 47 | + | <table class="blob-code"> |
|
| 48 | + | <tbody> |
|
| 49 | + | {{range $i, $line := .Data.ActiveBlob.Lines}} |
|
| 50 | + | <tr id="L{{add $i 1}}"> |
|
| 51 | + | <td class="line-num"><a href="#L{{add $i 1}}">{{add $i 1}}</a></td> |
|
| 52 | + | <td class="line-code"><pre{{with langClass $.Data.ActiveBlob.Name}} class="{{.}}"{{end}}>{{$line}}</pre></td> |
|
| 53 | + | </tr> |
|
| 54 | + | {{end}} |
|
| 55 | + | </tbody> |
|
| 56 | + | </table> |
|
| 57 | + | </div> |
|
| 58 | + | {{end}} |
|
| 59 | + | </div> |
|
| 60 | + | {{else if .Data.Readme}} |
|
| 61 | + | <div class="file-view"> |
|
| 62 | + | <div class="file-header"> |
|
| 63 | + | <span class="file-name">{{.Data.Readme.Name}}</span> |
|
| 64 | + | </div> |
|
| 65 | + | <pre>{{.Data.Readme.Content}}</pre> |
|
| 66 | + | </div> |
|
| 67 | + | {{end}} |
|
| 68 | + | </div> |
|
| 69 | + | </div> |
|
| 70 | + | ||
| 71 | + | {{end}} |
|
| 72 | + | {{end}} |
templates/index.html
added
+24 -0
| 1 | + | {{define "content"}} |
|
| 2 | + | {{if .Data.IsEmpty}} |
|
| 3 | + | <p class="empty">No repositories found.</p> |
|
| 4 | + | {{else}} |
|
| 5 | + | <table class="repo-list"> |
|
| 6 | + | <thead> |
|
| 7 | + | <tr> |
|
| 8 | + | <th>Repository</th> |
|
| 9 | + | <th>Description</th> |
|
| 10 | + | <th>Idle</th> |
|
| 11 | + | </tr> |
|
| 12 | + | </thead> |
|
| 13 | + | <tbody> |
|
| 14 | + | {{range .Data.Repos}} |
|
| 15 | + | <tr> |
|
| 16 | + | <td><a href="{{$.BaseURL}}/{{.Name}}/">{{.Name}}</a></td> |
|
| 17 | + | <td>{{.Description}}</td> |
|
| 18 | + | <td>{{if not .LastUpdated.IsZero}}{{timeAgo .LastUpdated}}{{end}}</td> |
|
| 19 | + | </tr> |
|
| 20 | + | {{end}} |
|
| 21 | + | </tbody> |
|
| 22 | + | </table> |
|
| 23 | + | {{end}} |
|
| 24 | + | {{end}} |
templates/layout.html
added
+37 -0
| 1 | + | <!DOCTYPE html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="utf-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> |
|
| 6 | + | <title>{{if .Repo}}{{.Repo}} · {{if eq .Section "home"}}{{.Description}}{{else}}{{.Section}}{{end}}{{else}}{{.SiteTitle}}{{end}}</title> |
|
| 7 | + | <link rel="stylesheet" href="{{.BaseURL}}/style.css"> |
|
| 8 | + | </head> |
|
| 9 | + | <body> |
|
| 10 | + | <div class="container"> |
|
| 11 | + | {{if not .Repo}} |
|
| 12 | + | <nav class="repo-nav"> |
|
| 13 | + | <span class="repo-name"><a href="{{.BaseURL}}/" class="logo-link"><img class="logo" src="{{.BaseURL}}/radiant.svg" alt="" width="16" height="16"></a><a href="{{.BaseURL}}/">{{.SiteTitle}}</a></span> |
|
| 14 | + | </nav> |
|
| 15 | + | {{end}} |
|
| 16 | + | {{if .Repo}} |
|
| 17 | + | <nav class="repo-nav"> |
|
| 18 | + | <span class="repo-name"><a href="{{.BaseURL}}/" class="logo-link"><img class="logo" src="{{.BaseURL}}/radiant.svg" alt="" width="16" height="16"></a><a href="{{.BaseURL}}/{{.Repo}}/">{{.Repo}}</a>{{if .Description}}<span class="repo-desc">{{.Description}}</span>{{end}}</span> |
|
| 19 | + | <a href="{{.BaseURL}}/{{.Repo}}/" class="tab{{if eq .Section "home"}} active{{end}}">home</a> |
|
| 20 | + | <a href="{{.BaseURL}}/{{.Repo}}/log/{{.Ref}}" class="tab{{if eq .Section "log"}} active{{end}}">log</a> |
|
| 21 | + | <a href="{{.BaseURL}}/{{.Repo}}/refs" class="tab{{if eq .Section "refs"}} active{{end}}">refs</a> |
|
| 22 | + | {{if .CommitHash}}<a href="{{.BaseURL}}/{{.Repo}}/commit/{{.CommitHash}}" class="tab tab-mono active">{{shortHash .CommitHash}}</a>{{end}} |
|
| 23 | + | </nav> |
|
| 24 | + | {{end}} |
|
| 25 | + | <main> |
|
| 26 | + | {{template "content" .}} |
|
| 27 | + | </main> |
|
| 28 | + | {{if not .Repo}} |
|
| 29 | + | <footer> |
|
| 30 | + | <p>© 2026 Radiant Computer</p> |
|
| 31 | + | </footer> |
|
| 32 | + | {{end}} |
|
| 33 | + | </div> |
|
| 34 | + | <script src="{{.BaseURL}}/js/hirad.js"></script> |
|
| 35 | + | <script src="{{.BaseURL}}/js/hiril.js"></script> |
|
| 36 | + | </body> |
|
| 37 | + | </html> |
templates/log.html
added
+50 -0
| 1 | + | {{define "content"}} |
|
| 2 | + | <nav class="view-nav"> |
|
| 3 | + | <details class="branch-selector"> |
|
| 4 | + | <summary>{{.Data.Ref}}</summary> |
|
| 5 | + | <ul> |
|
| 6 | + | {{range .Data.Branches}} |
|
| 7 | + | <li{{if eq .Name $.Data.Ref}} class="active"{{end}}><a href="{{$.BaseURL}}/{{$.Repo}}/log/{{.Name}}">{{.Name}}</a></li> |
|
| 8 | + | {{end}} |
|
| 9 | + | </ul> |
|
| 10 | + | </details> |
|
| 11 | + | {{if .Data.LastCommit}} |
|
| 12 | + | <div class="last-commit"> |
|
| 13 | + | <span class="hash"><a href="{{.BaseURL}}/{{.Repo}}/commit/{{.Data.LastCommit.Hash}}">{{.Data.LastCommit.ShortHash}}</a></span> |
|
| 14 | + | <span class="subject">{{.Data.LastCommit.Subject}}</span> |
|
| 15 | + | <span class="author">{{.Data.LastCommit.Author}}</span> |
|
| 16 | + | <span class="date">{{timeAgo .Data.LastCommit.AuthorDate}}</span> |
|
| 17 | + | </div> |
|
| 18 | + | {{end}} |
|
| 19 | + | </nav> |
|
| 20 | + | ||
| 21 | + | {{if .Data.IsEmpty}} |
|
| 22 | + | <p class="empty">No commits found.</p> |
|
| 23 | + | {{else}} |
|
| 24 | + | <table class="log-list"> |
|
| 25 | + | <thead> |
|
| 26 | + | <tr> |
|
| 27 | + | <th>Hash</th> |
|
| 28 | + | <th>Subject</th> |
|
| 29 | + | <th>Author</th> |
|
| 30 | + | <th>Age</th> |
|
| 31 | + | </tr> |
|
| 32 | + | </thead> |
|
| 33 | + | <tbody> |
|
| 34 | + | {{range .Data.Commits}} |
|
| 35 | + | <tr> |
|
| 36 | + | <td class="hash"><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.ShortHash}}</a></td> |
|
| 37 | + | <td class="subject">{{.Subject}}</td> |
|
| 38 | + | <td class="author">{{.Author}}</td> |
|
| 39 | + | <td class="date">{{timeAgo .AuthorDate}}</td> |
|
| 40 | + | </tr> |
|
| 41 | + | {{end}} |
|
| 42 | + | </tbody> |
|
| 43 | + | </table> |
|
| 44 | + | ||
| 45 | + | <div class="pagination"> |
|
| 46 | + | {{if .Data.HasPrev}}<a href="?page={{.Data.PrevPage}}">← Newer</a>{{end}} |
|
| 47 | + | {{if .Data.HasNext}}<a href="?page={{.Data.NextPage}}">Older →</a>{{end}} |
|
| 48 | + | </div> |
|
| 49 | + | {{end}} |
|
| 50 | + | {{end}} |
templates/refs.html
added
+51 -0
| 1 | + | {{define "content"}} |
|
| 2 | + | {{if .Data.Branches}} |
|
| 3 | + | <table class="ref-list"> |
|
| 4 | + | <thead> |
|
| 5 | + | <tr> |
|
| 6 | + | <th>Branch</th> |
|
| 7 | + | <th>Hash</th> |
|
| 8 | + | <th>Subject</th> |
|
| 9 | + | <th>Author</th> |
|
| 10 | + | <th>Idle</th> |
|
| 11 | + | </tr> |
|
| 12 | + | </thead> |
|
| 13 | + | <tbody> |
|
| 14 | + | {{range .Data.Branches}} |
|
| 15 | + | <tr> |
|
| 16 | + | <td><a href="{{$.BaseURL}}/{{$.Repo}}/log/{{.Name}}">{{.Name}}</a></td> |
|
| 17 | + | <td class="hash"><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.ShortHash}}</a></td> |
|
| 18 | + | <td class="subject">{{.Subject}}</td> |
|
| 19 | + | <td class="author">{{.Author}}</td> |
|
| 20 | + | <td class="date">{{timeAgo .Date}}</td> |
|
| 21 | + | </tr> |
|
| 22 | + | {{end}} |
|
| 23 | + | </tbody> |
|
| 24 | + | </table> |
|
| 25 | + | {{end}} |
|
| 26 | + | ||
| 27 | + | {{if .Data.Tags}} |
|
| 28 | + | <table class="ref-list"> |
|
| 29 | + | <thead> |
|
| 30 | + | <tr> |
|
| 31 | + | <th>Tag</th> |
|
| 32 | + | <th>Hash</th> |
|
| 33 | + | <th>Subject</th> |
|
| 34 | + | <th>Author</th> |
|
| 35 | + | <th>Idle</th> |
|
| 36 | + | </tr> |
|
| 37 | + | </thead> |
|
| 38 | + | <tbody> |
|
| 39 | + | {{range .Data.Tags}} |
|
| 40 | + | <tr> |
|
| 41 | + | <td><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.Name}}</a></td> |
|
| 42 | + | <td class="hash"><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.ShortHash}}</a></td> |
|
| 43 | + | <td class="subject">{{.Subject}}</td> |
|
| 44 | + | <td class="author">{{.Author}}</td> |
|
| 45 | + | <td class="date">{{timeAgo .Date}}</td> |
|
| 46 | + | </tr> |
|
| 47 | + | {{end}} |
|
| 48 | + | </tbody> |
|
| 49 | + | </table> |
|
| 50 | + | {{end}} |
|
| 51 | + | {{end}} |
watch
added
+5 -0
| 1 | + | #!/bin/sh |
|
| 2 | + | # Rebuild and restart on source changes. |
|
| 3 | + | # Requires `entr(1)`. |
|
| 4 | + | find . -name '*.go' -o -name '*.html' -o -name '*.css' \ |
|
| 5 | + | | entr -r go run . -scan-path ~/src/radiant -non-bare "$@" |