initial commit
This commit is contained in:
commit
d7a964c186
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Local config
|
||||||
|
config.toml
|
||||||
|
|
||||||
|
# Binary artefacts
|
||||||
|
teafolio
|
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Dockerfile for production Teafolio deployments
|
||||||
|
|
||||||
|
FROM golang:1.14-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN go build -ldflags "-s -w" && chmod +x teafolio
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/teafolio /app/teafolio
|
||||||
|
COPY /static /app/static
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/app/teafolio" ]
|
19
README.md
Normal file
19
README.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# teafolio
|
||||||
|
|
||||||
|
Teafolio is a web-based portfolio frontend for a Gitea server.
|
||||||
|
|
||||||
|
Compared to the earlier [codesite](https://code.ivysaur.me/codesite/) project, the repository list and detailed information is loaded live from a Gitea server.
|
||||||
|
|
||||||
|
Written in Go
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Compile the binary: `go build`
|
||||||
|
2. Modify the sample `config.toml` file to point to your Gitea instance
|
||||||
|
- `teafolio` will look for `config.toml` in the current directory, or, you can supply a custom path with `-ConfigFile`
|
||||||
|
3. Deploy binary + `static/` directory to webserver
|
||||||
|
|
||||||
|
### Production (Docker)
|
||||||
|
|
||||||
|
1. `docker build -t teafolio:latest .`
|
||||||
|
2. `docker run --restart=always -d -p 5656:5656 -v $(pwd)/config.toml:/app/config.toml teafolio:latest`
|
204
api.go
Normal file
204
api.go
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentsResponse struct {
|
||||||
|
Content []byte `json:"content"` // Assume base64 "encoding" parameter in Gitea response, and use Go's auto decode
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopicsResponse struct {
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReaddirEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
RawURL string `json:"download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rde ReaddirEntry) isImage() bool {
|
||||||
|
return strings.HasSuffix(rde.Name, `.png`) || strings.HasSuffix(rde.Name, `.jpg`) || strings.HasSuffix(rde.Name, `.jpeg`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkdownRequest struct {
|
||||||
|
Context string
|
||||||
|
Mode string
|
||||||
|
Text string
|
||||||
|
Wiki bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// repos gets a list of Git repositories in this organisation.
|
||||||
|
func (this *Application) repos() ([]Repo, error) {
|
||||||
|
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/orgs/` + url.PathEscape(this.cfg.Gitea.Org) + `/repos`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var repos []Repo
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&repos)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return repos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoFile gets a single file from the default branch of the git repository
|
||||||
|
// Usually the default branch is `master`.
|
||||||
|
func (this *Application) repoFile(repo, filename string) ([]byte, error) {
|
||||||
|
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/contents/` + url.PathEscape(filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cr ContentsResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&cr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cr.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) filesInDirectory(repo, dir string) ([]ReaddirEntry, error) {
|
||||||
|
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/contents/` + dir) // n.b. $dir param not escaped
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
|
||||||
|
// "No files found" happens with a HTTP 500/404 error depending on Gitea version. Catch this special case
|
||||||
|
if resp.StatusCode == 500 || resp.StatusCode == 404 {
|
||||||
|
b, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), `does not exist`) {
|
||||||
|
return []ReaddirEntry{}, nil // no files found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret []ReaddirEntry
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&ret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageFilesForRepo finds documentation images for the repository.
|
||||||
|
// It searches the dist/ and doc/ subdirectories.
|
||||||
|
func (this *Application) imageFilesForRepo(repo string) ([]ReaddirEntry, error) {
|
||||||
|
|
||||||
|
ret := []ReaddirEntry{}
|
||||||
|
|
||||||
|
for _, dirName := range []string{`dist`, `doc`} {
|
||||||
|
|
||||||
|
files, err := this.filesInDirectory(repo, dirName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("readdir(%s): %w", dirName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if f.isImage() {
|
||||||
|
ret = append(ret, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) topicsForRepo(repo string) ([]string, error) {
|
||||||
|
resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/topics`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tr TopicsResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&tr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr.Topics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMarkdown calls the remote Gitea server's own markdown renderer.
|
||||||
|
func (this *Application) renderMarkdown(repoName string, body string) ([]byte, error) {
|
||||||
|
req := MarkdownRequest{
|
||||||
|
Context: this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) + `/src/branch/master`,
|
||||||
|
Mode: "gfm", // magic constant - Github Flavoured Markdown
|
||||||
|
Text: body,
|
||||||
|
}
|
||||||
|
|
||||||
|
jb, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/`, `application/json`, bytes.NewReader(jb))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ioutil.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMarkdownRaw calls the remote Gitea server's own markdown renderer.
|
||||||
|
func (this *Application) renderMarkdownRaw(body []byte) ([]byte, error) {
|
||||||
|
resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/raw`, `text/plain`, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ioutil.ReadAll(resp.Body)
|
||||||
|
}
|
16
config.toml.sample
Normal file
16
config.toml.sample
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# teafolio config file
|
||||||
|
|
||||||
|
BindTo="0.0.0.0:5656"
|
||||||
|
|
||||||
|
[Gitea]
|
||||||
|
URL="https://gitea.com/"
|
||||||
|
Org="gitea"
|
||||||
|
|
||||||
|
[Template]
|
||||||
|
AppName = "Teafolio"
|
||||||
|
|
||||||
|
HomepageHeaderHTML="""
|
||||||
|
<p>
|
||||||
|
Teafolio is a web-based portfolio frontend for a Gitea server.
|
||||||
|
</p>
|
||||||
|
"""
|
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module teafolio
|
||||||
|
|
||||||
|
go 1.13
|
||||||
|
|
||||||
|
require github.com/BurntSushi/toml v0.3.1
|
2
go.sum
Normal file
2
go.sum
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
327
main.go
Normal file
327
main.go
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
BindTo string
|
||||||
|
Gitea struct {
|
||||||
|
URL, Org string
|
||||||
|
}
|
||||||
|
Template struct {
|
||||||
|
AppName string
|
||||||
|
HomepageHeaderHTML string
|
||||||
|
CustomLogoPngBase64 string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Application struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
rxRepoPage, rxRepoImage *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) Templatepage(w http.ResponseWriter, r *http.Request, pageDesc, extraHead string, cb func()) {
|
||||||
|
|
||||||
|
pageTitle := this.cfg.Template.AppName
|
||||||
|
if pageDesc != "" {
|
||||||
|
pageTitle = pageDesc + ` | ` + pageTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set(`Content-Type`, `text/html; charset=UTF-8`)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
fmt.Fprint(w, `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=960">
|
||||||
|
<title>`+html.EscapeString(pageTitle)+`</title>
|
||||||
|
`+extraHead+`
|
||||||
|
<link rel="shortcut icon" href="/static/logo.png" type="image/png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/logo.png" type="image/png">
|
||||||
|
<link type="text/css" rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container">
|
||||||
|
<div id="content">
|
||||||
|
<h1><a href="/"><div id="logo"></div>`+html.EscapeString(this.cfg.Template.AppName)+`</a></h1>
|
||||||
|
`)
|
||||||
|
cb()
|
||||||
|
fmt.Fprint(w, `
|
||||||
|
</body>
|
||||||
|
<script type="text/javascript" src="/static/site.js"></script>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) internalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
log.Printf("%s %s: %s", r.Method, r.URL.Path, err)
|
||||||
|
http.Error(w, "An internal error occurred.", 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repos, err := this.repos()
|
||||||
|
if err != nil {
|
||||||
|
this.internalError(w, r, fmt.Errorf("listing repos: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topics := make(map[string][]string)
|
||||||
|
for _, repo := range repos {
|
||||||
|
if t, err := this.topicsForRepo(repo.Name); err == nil {
|
||||||
|
topics[repo.Name] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready for template
|
||||||
|
|
||||||
|
this.Templatepage(w, r, "", "", func() {
|
||||||
|
fmt.Fprint(w, `
|
||||||
|
`+this.cfg.Template.HomepageHeaderHTML+`
|
||||||
|
<h2>Projects</h2>
|
||||||
|
<table id="projtable-main" class="projtable">
|
||||||
|
`)
|
||||||
|
for _, repo := range repos {
|
||||||
|
pageHref := html.EscapeString(`/` + url.PathEscape(repo.Name))
|
||||||
|
|
||||||
|
normalisedDesc := repo.Description
|
||||||
|
normalisedDesc = strings.TrimRight(repo.Description, `.`)
|
||||||
|
if len(normalisedDesc) > 0 {
|
||||||
|
// Lowercase the first letter of the description, unless it starts with an acronym (all letters uppercase first word) or CamelCase word
|
||||||
|
firstWord := strings.SplitN(normalisedDesc, " ", 2)[0]
|
||||||
|
isAcronymOrCamelCase := len(firstWord) > 1 && (firstWord[1:] != strings.ToLower(firstWord[1:]))
|
||||||
|
|
||||||
|
if !(isAcronymOrCamelCase || firstWord == `Go`) {
|
||||||
|
normalisedDesc = strings.ToLower(normalisedDesc[0:1]) + normalisedDesc[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add leading `<COMMA><SPACE>` to separate from the repo title
|
||||||
|
normalisedDesc = `, ` + normalisedDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
rowClass := ""
|
||||||
|
for _, topic := range topics[repo.Name] {
|
||||||
|
rowClass += `taggedWith-` + topic + ` `
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, `
|
||||||
|
<tr class="`+html.EscapeString(rowClass)+`">
|
||||||
|
<td>
|
||||||
|
<a href="`+pageHref+`"><img class="homeimage" src="`+html.EscapeString(`/:banner/`+url.PathEscape(repo.Name))+`"></div></a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>`+html.EscapeString(repo.Name)+`</strong>`+html.EscapeString(normalisedDesc)+`
|
||||||
|
<a href="`+pageHref+`" class="article-read-more">more...</a>
|
||||||
|
<br>
|
||||||
|
<small>
|
||||||
|
`)
|
||||||
|
for _, topic := range topics[repo.Name] {
|
||||||
|
fmt.Fprint(w, `<a class="tag tag-link" data-tag="`+html.EscapeString(topic)+`">`+html.EscapeString(topic)+`</a> `)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `
|
||||||
|
</table>
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) {
|
||||||
|
|
||||||
|
images, err := this.imageFilesForRepo(repoName)
|
||||||
|
if err != nil {
|
||||||
|
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(images) == 0 {
|
||||||
|
w.Header().Set(`Location`, `/static/no_image.png`)
|
||||||
|
w.WriteHeader(301)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set(`Location`, images[0].RawURL)
|
||||||
|
w.WriteHeader(301)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoName string) {
|
||||||
|
|
||||||
|
repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName)
|
||||||
|
extraHead := ""
|
||||||
|
|
||||||
|
readme, err := this.repoFile(repoName, `README.md`)
|
||||||
|
if err != nil {
|
||||||
|
this.internalError(w, r, fmt.Errorf("loading README.md: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(readme), "\n")
|
||||||
|
|
||||||
|
// We add some extra badges based on special text entries
|
||||||
|
extraBadgesMd := ` ![](https://img.shields.io/badge/build-success-brightgreen)`
|
||||||
|
extraBadgesMd += ` [![](https://img.shields.io/badge/vcs-git-green?logo=git)](` + repoURL + `)`
|
||||||
|
|
||||||
|
// Convert [`Written in LANG` "\n"] to badge
|
||||||
|
// This was special syntax used by codesite
|
||||||
|
writtenInPrefix := `Written in `
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(line, writtenInPrefix) {
|
||||||
|
extraBadgesMd += ` ![](https://img.shields.io/badge/written%20in-` + url.QueryEscape(line[len(writtenInPrefix):]) + `-blue)`
|
||||||
|
lines = append(lines[0:i], lines[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject more badges to 3rd line; or, create badges on 3rd line if there are none already
|
||||||
|
if len(lines) >= 3 && strings.Contains(lines[2], `shields.io`) {
|
||||||
|
lines[2] += ` ` + extraBadgesMd
|
||||||
|
} else {
|
||||||
|
// Push other lines down
|
||||||
|
lines = append([]string{lines[0], lines[1], extraBadgesMd, ""}, lines[2:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
readmeHtml, err := this.renderMarkdown(repoName, strings.Join(lines, "\n"))
|
||||||
|
if err != nil {
|
||||||
|
this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := this.imageFilesForRepo(repoName)
|
||||||
|
if err != nil {
|
||||||
|
this.internalError(w, r, fmt.Errorf("listing images: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the Git repository contains a top-level go.mod file, allow vanity imports
|
||||||
|
if goMod, err := this.repoFile(repoName, `go.mod`); err == nil {
|
||||||
|
|
||||||
|
// Check the first line should be `module MODULENAME\n`
|
||||||
|
firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0]
|
||||||
|
if bytes.HasPrefix(firstLine, []byte("module ")) {
|
||||||
|
moduleName := firstLine[7:]
|
||||||
|
extraHead = `<meta name="go-import" content="` + html.EscapeString(string(moduleName)) + ` git ` + repoURL + `.git">`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-escalate all headers in rendered markdown to match our style
|
||||||
|
repl := strings.NewReplacer(`<h1`, `<h2`, `<h2`, `<h3`, `<h3`, `<h4`,
|
||||||
|
`</h1>`, `</h2>`, `</h2>`, `</h3>`, `</h3>`, `</h4>`)
|
||||||
|
|
||||||
|
// Ready for template
|
||||||
|
|
||||||
|
this.Templatepage(w, r, repoName, extraHead, func() {
|
||||||
|
|
||||||
|
projBodyclass := `projbody`
|
||||||
|
if len(images) > 0 {
|
||||||
|
projBodyclass += ` projbody_halfw`
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, `<div class="projinfo"><div class="`+projBodyclass+`">`)
|
||||||
|
repl.WriteString(w, string(readmeHtml))
|
||||||
|
fmt.Fprint(w, `</div>`)
|
||||||
|
|
||||||
|
if len(images) > 0 {
|
||||||
|
fmt.Fprint(w, `<div class="projimg">`)
|
||||||
|
for _, img := range images {
|
||||||
|
fmt.Fprint(w, `<a href="`+html.EscapeString(img.RawURL)+`"><img alt="" class="thumbimage" src="`+html.EscapeString(img.RawURL)+`" /></a>`)
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, `<div style="clear:both;"></div>`)
|
||||||
|
fmt.Fprint(w, `</div>`) // projbody
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (this *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == `GET` {
|
||||||
|
if r.URL.Path == `/` {
|
||||||
|
this.Homepage(w, r)
|
||||||
|
|
||||||
|
} else if r.URL.Path == `/favicon.ico` {
|
||||||
|
w.Header().Set(`Location`, `/static/logo.png`)
|
||||||
|
w.WriteHeader(301)
|
||||||
|
|
||||||
|
} else if r.URL.Path == `/robots.txt` {
|
||||||
|
http.Error(w, "not found", 404)
|
||||||
|
|
||||||
|
} else if parts := this.rxRepoImage.FindStringSubmatch(r.URL.Path); parts != nil {
|
||||||
|
this.Bannerpage(w, r, parts[1])
|
||||||
|
|
||||||
|
} else if parts := this.rxRepoPage.FindStringSubmatch(r.URL.Path); parts != nil {
|
||||||
|
this.Repopage(w, r, parts[1])
|
||||||
|
|
||||||
|
} else if r.URL.Path == `/static/logo.png` {
|
||||||
|
if this.cfg.Template.CustomLogoPngBase64 != "" {
|
||||||
|
|
||||||
|
logoPng, err := base64.StdEncoding.DecodeString(this.cfg.Template.CustomLogoPngBase64)
|
||||||
|
if err != nil {
|
||||||
|
this.internalError(w, r, fmt.Errorf("parsing base64 logo: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(logoPng)))
|
||||||
|
w.Header().Set(`Content-Type`, `image/png`)
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(logoPng)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
r.URL.Path = r.URL.Path[8:]
|
||||||
|
http.FileServer(http.Dir(`static`)).ServeHTTP(w, r)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if strings.HasPrefix(r.URL.Path, `/static/`) {
|
||||||
|
r.URL.Path = r.URL.Path[8:]
|
||||||
|
http.FileServer(http.Dir(`static`)).ServeHTTP(w, r)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
http.Error(w, "not found", 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
http.Error(w, "invalid method", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := Application{
|
||||||
|
rxRepoPage: regexp.MustCompile(`^/([^/]+)/?$`),
|
||||||
|
rxRepoImage: regexp.MustCompile(`^/:banner/([^/]+)/?$`),
|
||||||
|
}
|
||||||
|
|
||||||
|
configFile := flag.String(`ConfigFile`, `config.toml`, `Configuration file in TOML format`)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
_, err := toml.DecodeFile(*configFile, &app.cfg)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert Gitea URL always has trailing slash
|
||||||
|
if !strings.HasSuffix(app.cfg.Gitea.URL, `/`) {
|
||||||
|
app.cfg.Gitea.URL += `/`
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(app.cfg.BindTo, &app))
|
||||||
|
}
|
BIN
static/greyzz.png
Normal file
BIN
static/greyzz.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 243 B |
BIN
static/no_image.png
Normal file
BIN
static/no_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 436 B |
83
static/site.js
Normal file
83
static/site.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
//
|
||||||
|
// Tag support
|
||||||
|
//
|
||||||
|
|
||||||
|
var show_all = function() {
|
||||||
|
var tr = document.querySelectorAll(".projtable tr");
|
||||||
|
for (var i = 0, e = tr.length; i !== e; ++i) {
|
||||||
|
tr[i].style.display = "table-row";
|
||||||
|
}
|
||||||
|
|
||||||
|
var warn = document.querySelector(".tag-filter-warn");
|
||||||
|
warn.parentNode.removeChild(warn);
|
||||||
|
};
|
||||||
|
|
||||||
|
var show_tag = function(tag) {
|
||||||
|
if (document.querySelector(".tag-filter-warn") !== null) {
|
||||||
|
show_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tr = document.querySelectorAll(".projtable tr");
|
||||||
|
for (var i = 0, e = tr.length; i !== e; ++i) {
|
||||||
|
tr[i].style.display = (tr[i].className.split(" ").indexOf("taggedWith-"+tag) === -1) ? "none" : "table-row";
|
||||||
|
}
|
||||||
|
|
||||||
|
var div = document.createElement("div");
|
||||||
|
div.className = "tag-filter-warn";
|
||||||
|
div.innerHTML = "Filtering by tag. <a>reset</a>";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
|
||||||
|
document.querySelector(".tag-filter-warn a").addEventListener('click', function() {
|
||||||
|
show_all();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var get_show_tag = function(tag) {
|
||||||
|
return function() {
|
||||||
|
show_tag(tag);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
var taglinks = document.querySelectorAll(".tag-link");
|
||||||
|
for (var i = 0, e = taglinks.length; i !== e; ++i) {
|
||||||
|
var tag = taglinks[i].getAttribute("data-tag");
|
||||||
|
taglinks[i].addEventListener('click', get_show_tag(tag));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// Sort support (theme opt-in)
|
||||||
|
//
|
||||||
|
|
||||||
|
var sort_rows = function(cb) {
|
||||||
|
var tr = document.querySelectorAll(".projtable tr");
|
||||||
|
var items = [];
|
||||||
|
for (var i = 0, e = tr.length; i !== e; ++i) {
|
||||||
|
items.push([i, cb(tr[i])]);
|
||||||
|
}
|
||||||
|
items.sort(function(a, b) {
|
||||||
|
return (a[1] - b[1]);
|
||||||
|
});
|
||||||
|
for (var i = 0, e = items.length; i !== e; ++i) {
|
||||||
|
var el = tr[items[i][0]];
|
||||||
|
var parent = el.parentElement;
|
||||||
|
parent.removeChild(el);
|
||||||
|
parent.appendChild(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var sort_update = function(sort_by) {
|
||||||
|
sort_rows(function(el) {
|
||||||
|
return el.getAttribute(sort_by);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.sortUpdate = sort_update;
|
||||||
|
|
||||||
|
})();
|
221
static/style.css
Normal file
221
static/style.css
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||||
|
html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}
|
||||||
|
|
||||||
|
|
||||||
|
/* style.css */
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-y:scroll; /* always display scrollbar to prevent horizontal lurch */
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border:0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color:#4078c0;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
cursor:pointer;
|
||||||
|
text-decoration:underline;
|
||||||
|
}
|
||||||
|
h1 a {
|
||||||
|
color:black;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
h1 a:hover {
|
||||||
|
color:black;
|
||||||
|
}
|
||||||
|
h1,h2,h3 {
|
||||||
|
margin-top:0;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
background: #F8F8F8;
|
||||||
|
font-family:Consolas,monospace;
|
||||||
|
white-space:pre;
|
||||||
|
}
|
||||||
|
.code-multiline {
|
||||||
|
display:inline-block;
|
||||||
|
padding:8px;
|
||||||
|
border-radius:8px;
|
||||||
|
}
|
||||||
|
.content-paragraph {
|
||||||
|
/* mimic default <p> margins */
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
/* structural */
|
||||||
|
height:100%;
|
||||||
|
min-height:100%;
|
||||||
|
margin:0;
|
||||||
|
border:0;
|
||||||
|
padding:0;
|
||||||
|
|
||||||
|
/* cosmetic */
|
||||||
|
font-family:"Helvetica Neue","Segoe UI",Arial,sans-serif;
|
||||||
|
font-size:13px;
|
||||||
|
line-height:1.4;
|
||||||
|
background:#DDD url('greyzz.png'); /* thanks subtlepatterns.com ! */
|
||||||
|
color:#333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container {
|
||||||
|
margin:0 auto;
|
||||||
|
width:960px;
|
||||||
|
position:relative;
|
||||||
|
|
||||||
|
height:auto !important;
|
||||||
|
height:100%; /* oldIE */
|
||||||
|
min-height:100%;
|
||||||
|
|
||||||
|
/* cosmetic */
|
||||||
|
background:white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
padding:14px;
|
||||||
|
background:white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
.tag::before {
|
||||||
|
content:"";
|
||||||
|
|
||||||
|
display:inline-block;
|
||||||
|
width:7px;
|
||||||
|
height:7px;
|
||||||
|
|
||||||
|
margin-right:2px;
|
||||||
|
|
||||||
|
background:transparent url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAZUlEQVQI12P4//8/Awyrqqp6KSkp/QdhFRWV7cgS+kDBN0B8RVlZuRikACahB+T8ger6BMTXwJJAiTwg4xvMOCj+ART3ZwCa3YYm8QcopgsyEWasDVT3W5AVMHcgO6gViNORXQ8A84NToxbSsJcAAAAASUVORK5CYII=') no-repeat 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter-warn {
|
||||||
|
position:fixed;
|
||||||
|
top:0;
|
||||||
|
right:0;
|
||||||
|
|
||||||
|
padding:4px;
|
||||||
|
|
||||||
|
background:lightyellow;
|
||||||
|
border-bottom: 1px solid #888;
|
||||||
|
border-left:1px solid #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
.projtable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.projtable tr {
|
||||||
|
transition:0.2s linear;
|
||||||
|
}
|
||||||
|
.projtable tr:hover {
|
||||||
|
background:#F8F8F8;
|
||||||
|
}
|
||||||
|
.projtable td {
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.projtable small {
|
||||||
|
color:grey;
|
||||||
|
font-style:italic;
|
||||||
|
}
|
||||||
|
.projtable tr td:first-child {
|
||||||
|
width:95px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projinfo {
|
||||||
|
}
|
||||||
|
.projbody {
|
||||||
|
}
|
||||||
|
.projbody_halfw {
|
||||||
|
float:left;
|
||||||
|
width: 860px; /* 740px full - 60px rhs column - 2px border */
|
||||||
|
}
|
||||||
|
.projbody_fullw {
|
||||||
|
|
||||||
|
}
|
||||||
|
.projimg {
|
||||||
|
float:right;
|
||||||
|
width:62px; /* 60px + 2px border */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
@media screen and (max-width:960px) {
|
||||||
|
|
||||||
|
#container {
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.projimg {
|
||||||
|
float:clear;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.projbody_halfw {
|
||||||
|
float:clear;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
#logo {
|
||||||
|
background:transparent url('logo.png') no-repeat 0 0;
|
||||||
|
width:24px;
|
||||||
|
height:24px;
|
||||||
|
display:inline-block;
|
||||||
|
margin-right:4px;
|
||||||
|
position:relative;
|
||||||
|
top:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
.homeimage {
|
||||||
|
width:90px;
|
||||||
|
height:32px;
|
||||||
|
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbimage {
|
||||||
|
width:60px;
|
||||||
|
height:60px;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition:0.2s opacity;
|
||||||
|
border:1px solid lightgrey;
|
||||||
|
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.thumbimage:hover {
|
||||||
|
opacity:1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-image {
|
||||||
|
width:90px;
|
||||||
|
height:32px;
|
||||||
|
display:block;
|
||||||
|
background: white url('no_image.png') no-repeat 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* */
|
||||||
|
|
||||||
|
.downloads-small {
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
list-style-type:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-small li:before {
|
||||||
|
content:"•";
|
||||||
|
}
|
||||||
|
.downloads-small li a:before {
|
||||||
|
font-weight:bold;
|
||||||
|
content:"⇩ ";
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user