diff options
| author | mo khan <mo@mokhan.ca> | 2026-01-30 18:16:31 -0700 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2026-01-30 18:16:31 -0700 |
| commit | feee7d43ef63ae607c6fd4cca88a356a93553ebe (patch) | |
| tree | 2969055a894dc4e72d8d79a9ac74cc30d78aff64 /internal | |
| parent | e0db8f82e96acadf6968e0cf9c805a7b22d835db (diff) | |
refactor: move packages to internal/
Diffstat (limited to 'internal')
50 files changed, 7676 insertions, 34 deletions
diff --git a/internal/generator/blob.go b/internal/generator/blob.go index 038754f..96c876a 100644 --- a/internal/generator/blob.go +++ b/internal/generator/blob.go @@ -15,10 +15,10 @@ import ( "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/links" - "mokhan.ca/antonmedv/gitmal/pkg/progress_bar" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/links" + "mokhan.ca/antonmedv/gitmal/internal/progress_bar" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) func GenerateBlobs(files []git.Blob, params Params) error { diff --git a/internal/generator/branches.go b/internal/generator/branches.go index 21a89f6..6c17b68 100644 --- a/internal/generator/branches.go +++ b/internal/generator/branches.go @@ -6,8 +6,8 @@ import ( "path/filepath" "sort" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) func GenerateBranches(branches []git.Ref, defaultBranch string, params Params) error { diff --git a/internal/generator/branches_json.go b/internal/generator/branches_json.go index 0b04a1a..ddf576f 100644 --- a/internal/generator/branches_json.go +++ b/internal/generator/branches_json.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "mokhan.ca/antonmedv/gitmal/pkg/git" + "mokhan.ca/antonmedv/gitmal/internal/git" ) type BranchJSON struct { diff --git a/internal/generator/commit.go b/internal/generator/commit.go index d372360..57a37c9 100644 --- a/internal/generator/commit.go +++ b/internal/generator/commit.go @@ -17,10 +17,10 @@ import ( "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/gitdiff" - "mokhan.ca/antonmedv/gitmal/pkg/progress_bar" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/gitdiff" + "mokhan.ca/antonmedv/gitmal/internal/progress_bar" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) func GenerateCommits(commits map[string]git.Commit, params Params) error { diff --git a/internal/generator/commits_atom.go b/internal/generator/commits_atom.go index 47952f1..f8a5dad 100644 --- a/internal/generator/commits_atom.go +++ b/internal/generator/commits_atom.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "mokhan.ca/antonmedv/gitmal/pkg/git" + "mokhan.ca/antonmedv/gitmal/internal/git" ) type AtomFeed struct { diff --git a/internal/generator/commits_json.go b/internal/generator/commits_json.go index 1b745b0..671cfa0 100644 --- a/internal/generator/commits_json.go +++ b/internal/generator/commits_json.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "mokhan.ca/antonmedv/gitmal/pkg/git" + "mokhan.ca/antonmedv/gitmal/internal/git" ) type CommitJSON struct { diff --git a/internal/generator/commits_list.go b/internal/generator/commits_list.go index 0fe07c7..9b0acbb 100644 --- a/internal/generator/commits_list.go +++ b/internal/generator/commits_list.go @@ -6,9 +6,9 @@ import ( "path/filepath" "slices" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/progress_bar" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/progress_bar" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) const commitsPerPage = 100 diff --git a/internal/generator/index.go b/internal/generator/index.go index 9296be9..0154427 100644 --- a/internal/generator/index.go +++ b/internal/generator/index.go @@ -6,9 +6,9 @@ import ( "sort" "strings" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/links" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/links" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) func GenerateIndex(files []git.Blob, params Params) error { diff --git a/internal/generator/list.go b/internal/generator/list.go index d389aab..4b4a66c 100644 --- a/internal/generator/list.go +++ b/internal/generator/list.go @@ -11,10 +11,10 @@ import ( "strings" "sync" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/links" - "mokhan.ca/antonmedv/gitmal/pkg/progress_bar" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/links" + "mokhan.ca/antonmedv/gitmal/internal/progress_bar" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) func GenerateLists(files []git.Blob, params Params) error { diff --git a/internal/generator/markdown.go b/internal/generator/markdown.go index 033693d..446465a 100644 --- a/internal/generator/markdown.go +++ b/internal/generator/markdown.go @@ -9,7 +9,7 @@ import ( "github.com/yuin/goldmark/parser" gmhtml "github.com/yuin/goldmark/renderer/html" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) func createMarkdown(style string) goldmark.Markdown { diff --git a/internal/generator/params.go b/internal/generator/params.go index a8103f0..54a81e1 100644 --- a/internal/generator/params.go +++ b/internal/generator/params.go @@ -1,6 +1,6 @@ package generator -import "mokhan.ca/antonmedv/gitmal/pkg/git" +import "mokhan.ca/antonmedv/gitmal/internal/git" type Params struct { Owner string diff --git a/internal/generator/post_process.go b/internal/generator/post_process.go index 278b15b..9e21056 100644 --- a/internal/generator/post_process.go +++ b/internal/generator/post_process.go @@ -16,7 +16,7 @@ import ( "github.com/tdewolff/minify/v2/html" "github.com/tdewolff/minify/v2/svg" - "mokhan.ca/antonmedv/gitmal/pkg/progress_bar" + "mokhan.ca/antonmedv/gitmal/internal/progress_bar" ) func PostProcessHTML(root string, doMinify bool, doGzip bool) error { diff --git a/internal/generator/readme.go b/internal/generator/readme.go index 1164e59..e79ca01 100644 --- a/internal/generator/readme.go +++ b/internal/generator/readme.go @@ -5,8 +5,8 @@ import ( "html/template" "strings" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/links" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/links" ) func readme(files []git.Blob, dirsSet, filesSet links.Set, params Params, rootHref string) template.HTML { diff --git a/internal/generator/tags.go b/internal/generator/tags.go index 679d9d6..b24418c 100644 --- a/internal/generator/tags.go +++ b/internal/generator/tags.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) func GenerateTags(entries []git.Tag, params Params) error { diff --git a/internal/generator/tags_atom.go b/internal/generator/tags_atom.go index 9a1e0d5..1632696 100644 --- a/internal/generator/tags_atom.go +++ b/internal/generator/tags_atom.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "mokhan.ca/antonmedv/gitmal/pkg/git" + "mokhan.ca/antonmedv/gitmal/internal/git" ) func GenerateTagsAtom(tags []git.Tag, params Params) error { diff --git a/internal/generator/themes.go b/internal/generator/themes.go index b753f49..032afa3 100644 --- a/internal/generator/themes.go +++ b/internal/generator/themes.go @@ -11,7 +11,7 @@ import ( "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) var ThemeStyles = map[string]string{ diff --git a/internal/generator/utils.go b/internal/generator/utils.go index 908d543..d9cb21d 100644 --- a/internal/generator/utils.go +++ b/internal/generator/utils.go @@ -6,8 +6,8 @@ import ( "path/filepath" "strings" - "mokhan.ca/antonmedv/gitmal/pkg/git" - "mokhan.ca/antonmedv/gitmal/pkg/templates" + "mokhan.ca/antonmedv/gitmal/internal/git" + "mokhan.ca/antonmedv/gitmal/internal/templates" ) const Dot = "ยท" diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..1e05d60 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,365 @@ +package git + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +func Branches(repoDir string, filter *regexp.Regexp, defaultBranch string) ([]Ref, error) { + cmd := exec.Command("git", "for-each-ref", "--format=%(refname:short)", "refs/heads/") + if repoDir != "" { + cmd.Dir = repoDir + } + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list branches: %w", err) + } + lines := strings.Split(string(out), "\n") + branches := make([]Ref, 0, len(lines)) + for _, line := range lines { + if line == "" { + continue + } + + if filter != nil && !filter.MatchString(line) && line != defaultBranch { + continue + } + branches = append(branches, NewRef(line)) + } + return branches, nil +} + +func Tags(repoDir string) ([]Tag, error) { + format := []string{ + "%(refname:short)", // tag name + "%(creatordate:unix)", // creation date + "%(objectname)", // commit hash for lightweight tags + "%(*objectname)", // peeled object => commit hash + } + args := []string{ + "for-each-ref", + "--sort=-creatordate", + "--format=" + strings.Join(format, "%00"), + "refs/tags", + } + cmd := exec.Command("git", args...) + if repoDir != "" { + cmd.Dir = repoDir + } + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list tags: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + tags := make([]Tag, 0, len(lines)) + + for _, line := range lines { + if line == "" { + continue + } + parts := strings.Split(line, "\x00") + if len(parts) != len(format) { + continue + } + name, timestamp, objectName, commitHash := parts[0], parts[1], parts[2], parts[3] + timestampInt, err := strconv.Atoi(timestamp) + if err != nil { + return nil, fmt.Errorf("failed to parse tag creation date: %w", err) + } + if commitHash == "" { + commitHash = objectName // tag is lightweight + } + tags = append(tags, Tag{ + Name: name, + Date: time.Unix(int64(timestampInt), 0), + CommitHash: commitHash, + }) + } + + return tags, nil +} + +func Files(ref Ref, repoDir string) ([]Blob, error) { + if ref.IsEmpty() { + ref = NewRef("HEAD") + } + + // -r: recurse into subtrees + // -l: include blob size + cmd := exec.Command("git", "ls-tree", "--full-tree", "-r", "-l", ref.String()) + if repoDir != "" { + cmd.Dir = repoDir + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to get stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start git ls-tree: %w", err) + } + + files := make([]Blob, 0, 256) + + // Read stdout line by line; each line is like: + // <mode> <type> <object> <size>\t<path> + // Example: "100644 blob e69de29... 12\tREADME.md" + scanner := bufio.NewScanner(stdout) + + // Allow long paths by increasing the scanner buffer limit + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + // Split header and path using the tab delimiter + // to preserve spaces in file names + tab := strings.IndexByte(line, '\t') + if tab == -1 { + return nil, fmt.Errorf("expected tab delimiter in ls-tree output: %s", line) + } + header := line[:tab] + path := line[tab+1:] + + // header fields: mode, type, object, size + parts := strings.Fields(header) + if len(parts) < 4 { + return nil, fmt.Errorf("unexpected ls-tree output format: %s", line) + } + modeNumber := parts[0] + typ := parts[1] + // object := parts[2] + sizeStr := parts[3] + + if typ != "blob" { + // We only care about files (blobs) + continue + } + + // Size could be "-" for non-blobs in some forms; + // for blobs it should be a number. + size, err := strconv.ParseInt(sizeStr, 10, 64) + if err != nil { + return nil, err + } + + mode, err := ParseFileMode(modeNumber) + if err != nil { + return nil, err + } + + files = append(files, Blob{ + Ref: ref, + Mode: mode, + Path: path, + FileName: filepath.Base(path), + Size: size, + }) + } + + if err := scanner.Err(); err != nil { + // Drain stderr to include any git error message + _ = cmd.Wait() + b, _ := io.ReadAll(stderr) + if len(b) > 0 { + return nil, fmt.Errorf("failed to read ls-tree output: %v: %s", err, string(b)) + } + return nil, fmt.Errorf("failed to read ls-tree output: %w", err) + } + + // Ensure the command completed successfully + if err := cmd.Wait(); err != nil { + b, _ := io.ReadAll(stderr) + if len(b) > 0 { + return nil, fmt.Errorf("git ls-tree %q failed: %v: %s", ref, err, string(b)) + } + return nil, fmt.Errorf("git ls-tree %q failed: %w", ref, err) + } + + return files, nil +} + +func BlobContent(ref Ref, path string, repoDir string) ([]byte, bool, error) { + if ref.IsEmpty() { + ref = NewRef("HEAD") + } + // Use `git show ref:path` to get the blob content at that ref + cmd := exec.Command("git", "show", ref.String()+":"+path) + if repoDir != "" { + cmd.Dir = repoDir + } + out, err := cmd.Output() + if err != nil { + // include stderr if available + if ee, ok := err.(*exec.ExitError); ok { + return nil, false, fmt.Errorf("git show failed: %v: %s", err, string(ee.Stderr)) + } + return nil, false, fmt.Errorf("git show failed: %w", err) + } + return out, IsBinary(out), nil +} + +func Commits(ref Ref, repoDir string) ([]Commit, error) { + format := []string{ + "%H", // commit hash + "%h", // abbreviated commit hash + "%s", // subject + "%b", // body + "%an", // author name + "%ae", // author email + "%ad", // author date + "%cn", // committer name + "%ce", // committer email + "%cd", // committer date + "%P", // parent hashes + "%D", // ref names without the "(", ")" wrapping. + } + + args := []string{ + "log", + "--date=unix", + "--pretty=format:" + strings.Join(format, "\x1F"), + "-z", // Separate the commits with NULs instead of newlines + ref.String(), + } + + cmd := exec.Command("git", args...) + if repoDir != "" { + cmd.Dir = repoDir + } + + out, err := cmd.Output() + if err != nil { + return nil, err + } + + lines := strings.Split(string(out), "\x00") + commits := make([]Commit, 0, len(lines)) + for _, line := range lines { + if line == "" { + continue + } + parts := strings.Split(line, "\x1F") + if len(parts) != len(format) { + return nil, fmt.Errorf("unexpected commit format: %s", line) + } + full, short, subject, body, author, email, date := + parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] + committerName, committerEmail, committerDate, parents, refs := + parts[7], parts[8], parts[9], parts[10], parts[11] + timestamp, err := strconv.Atoi(date) + if err != nil { + return nil, fmt.Errorf("failed to parse commit date: %w", err) + } + committerTimestamp, err := strconv.Atoi(committerDate) + if err != nil { + return nil, fmt.Errorf("failed to parse committer date: %w", err) + } + commits = append(commits, Commit{ + Hash: full, + ShortHash: short, + Subject: subject, + Body: body, + Author: author, + Email: email, + Date: time.Unix(int64(timestamp), 0), + CommitterName: committerName, + CommitterEmail: committerEmail, + CommitterDate: time.Unix(int64(committerTimestamp), 0), + Parents: strings.Fields(parents), + RefNames: parseRefNames(refs), + }) + } + return commits, nil +} + +func parseRefNames(refNames string) []RefName { + refNames = strings.TrimSpace(refNames) + if refNames == "" { + return nil + } + + parts := strings.Split(refNames, ", ") + out := make([]RefName, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // tag: v1.2.3 + if strings.HasPrefix(p, "tag: ") { + out = append(out, RefName{ + Kind: RefKindTag, + Name: strings.TrimSpace(strings.TrimPrefix(p, "tag: ")), + }) + continue + } + + // HEAD -> main + if strings.HasPrefix(p, "HEAD -> ") { + out = append(out, RefName{ + Kind: RefKindHEAD, + Name: "HEAD", + Target: strings.TrimSpace(strings.TrimPrefix(p, "HEAD -> ")), + }) + continue + } + + // origin/HEAD -> origin/main + if strings.Contains(p, " -> ") && strings.HasSuffix(strings.SplitN(p, " -> ", 2)[0], "/HEAD") { + leftRight := strings.SplitN(p, " -> ", 2) + out = append(out, RefName{ + Kind: RefKindRemoteHEAD, + Name: strings.TrimSpace(leftRight[0]), + Target: strings.TrimSpace(leftRight[1]), + }) + continue + } + + // Remote branch like origin/main + if strings.Contains(p, "/") { + out = append(out, RefName{ + Kind: RefKindRemote, + Name: p, + }) + continue + } + + // Local branch + out = append(out, RefName{ + Kind: RefKindBranch, + Name: p, + }) + } + return out +} + +func CommitDiff(hash, repoDir string) (string, error) { + // unified diff without a commit header + cmd := exec.Command("git", "show", "--pretty=format:", "--patch", hash) + if repoDir != "" { + cmd.Dir = repoDir + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/internal/git/types.go b/internal/git/types.go new file mode 100644 index 0000000..beecfa4 --- /dev/null +++ b/internal/git/types.go @@ -0,0 +1,76 @@ +package git + +import ( + "time" +) + +type Ref struct { + ref string + dirName string +} + +func NewRef(ref string) Ref { + return Ref{ + ref: ref, + dirName: RefToFileName(ref), + } +} + +func (r Ref) IsEmpty() bool { + return r.ref == "" +} + +func (r Ref) String() string { + return r.ref +} + +func (r Ref) DirName() string { + return r.dirName +} + +type Blob struct { + Ref Ref + Mode string + Path string + FileName string + Size int64 +} + +type Commit struct { + Hash string + ShortHash string + Subject string + Body string + Author string + Email string + Date time.Time + CommitterName string + CommitterEmail string + CommitterDate time.Time + Parents []string + Branch Ref + RefNames []RefName + Href string +} + +type RefKind string + +const ( + RefKindHEAD RefKind = "HEAD" + RefKindRemoteHEAD RefKind = "RemoteHEAD" + RefKindBranch RefKind = "Branch" + RefKindRemote RefKind = "Remote" + RefKindTag RefKind = "Tag" +) + +type RefName struct { + Kind RefKind + Name string // Name is the primary name of the ref as shown by `git log %D` token (left side for pointers) + Target string // Target is set for symbolic refs like "HEAD -> main" or "origin/HEAD -> origin/main" +} + +type Tag struct { + Name string + Date time.Time + CommitHash string +} diff --git a/internal/git/utils.go b/internal/git/utils.go new file mode 100644 index 0000000..68e4497 --- /dev/null +++ b/internal/git/utils.go @@ -0,0 +1,91 @@ +package git + +import ( + "fmt" + "strconv" + "strings" +) + +// ParseFileMode converts a git-style file mode (e.g. "100644") +// into a human-readable string like "rw-r--r--". +func ParseFileMode(modeStr string) (string, error) { + // Git modes are typically 6 digits. The last 3 represent permissions. + // e.g. 100644 โ 644 + if len(modeStr) < 3 { + return "", fmt.Errorf("invalid mode: %s", modeStr) + } + + permStr := modeStr[len(modeStr)-3:] + permVal, err := strconv.Atoi(permStr) + if err != nil { + return "", err + } + + return numericPermToLetters(permVal), nil +} + +func numericPermToLetters(perm int) string { + // Map each octal digit to rwx letters + lookup := map[int]string{ + 0: "---", + 1: "--x", + 2: "-w-", + 3: "-wx", + 4: "r--", + 5: "r-x", + 6: "rw-", + 7: "rwx", + } + + u := perm / 100 // user + g := (perm / 10) % 10 // group + o := perm % 10 // others + + return lookup[u] + lookup[g] + lookup[o] +} + +// IsBinary performs a heuristic check to determine if data is binary. +// Rules: +// - Any NUL byte => binary +// - Consider only a sample (up to 8 KiB). If >30% of bytes are control characters +// outside the common whitespace/newline range, treat as binary. +func IsBinary(b []byte) bool { + n := len(b) + if n == 0 { + return false + } + if n > 8192 { + n = 8192 + } + sample := b[:n] + bad := 0 + for _, c := range sample { + if c == 0x00 { + return true + } + // Allow common whitespace and control: tab(9), LF(10), CR(13) + if c == 9 || c == 10 || c == 13 { + continue + } + // Count other control chars and DEL as non-text + if c < 32 || c == 127 { + bad++ + } + } + // If more than 30% of sampled bytes are non-text, consider binary + return bad*100 > n*30 +} + +func RefToFileName(ref string) string { + var result strings.Builder + for _, c := range ref { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' { + result.WriteByte(byte(c)) + } else if c >= 'A' && c <= 'Z' { + result.WriteByte(byte(c - 'A' + 'a')) + } else { + result.WriteByte('-') + } + } + return result.String() +} diff --git a/internal/gitdiff/apply.go b/internal/gitdiff/apply.go new file mode 100644 index 0000000..44bbcca --- /dev/null +++ b/internal/gitdiff/apply.go @@ -0,0 +1,147 @@ +package gitdiff + +import ( + "errors" + "fmt" + "io" + "sort" +) + +// Conflict indicates an apply failed due to a conflict between the patch and +// the source content. +// +// Users can test if an error was caused by a conflict by using errors.Is with +// an empty Conflict: +// +// if errors.Is(err, &Conflict{}) { +// // handle conflict +// } +type Conflict struct { + msg string +} + +func (c *Conflict) Error() string { + return "conflict: " + c.msg +} + +// Is implements error matching for Conflict. Passing an empty instance of +// Conflict always returns true. +func (c *Conflict) Is(other error) bool { + if other, ok := other.(*Conflict); ok { + return other.msg == "" || other.msg == c.msg + } + return false +} + +// ApplyError wraps an error that occurs during patch application with +// additional location information, if it is available. +type ApplyError struct { + // Line is the one-indexed line number in the source data + Line int64 + // Fragment is the one-indexed fragment number in the file + Fragment int + // FragmentLine is the one-indexed line number in the fragment + FragmentLine int + + err error +} + +// Unwrap returns the wrapped error. +func (e *ApplyError) Unwrap() error { + return e.err +} + +func (e *ApplyError) Error() string { + return fmt.Sprintf("%v", e.err) +} + +type lineNum int +type fragNum int +type fragLineNum int + +// applyError creates a new *ApplyError wrapping err or augments the information +// in err with args if it is already an *ApplyError. Returns nil if err is nil. +func applyError(err error, args ...interface{}) error { + if err == nil { + return nil + } + + e, ok := err.(*ApplyError) + if !ok { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + e = &ApplyError{err: err} + } + for _, arg := range args { + switch v := arg.(type) { + case lineNum: + e.Line = int64(v) + 1 + case fragNum: + e.Fragment = int(v) + 1 + case fragLineNum: + e.FragmentLine = int(v) + 1 + } + } + return e +} + +var ( + errApplyInProgress = errors.New("gitdiff: incompatible apply in progress") + errApplierClosed = errors.New("gitdiff: applier is closed") +) + +// Apply applies the changes in f to src, writing the result to dst. It can +// apply both text and binary changes. +// +// If an error occurs while applying, Apply returns an *ApplyError that +// annotates the error with additional information. If the error is because of +// a conflict with the source, the wrapped error will be a *Conflict. +func Apply(dst io.Writer, src io.ReaderAt, f *File) error { + if f.IsBinary { + if len(f.TextFragments) > 0 { + return applyError(errors.New("binary file contains text fragments")) + } + if f.BinaryFragment == nil { + return applyError(errors.New("binary file does not contain a binary fragment")) + } + } else { + if f.BinaryFragment != nil { + return applyError(errors.New("text file contains a binary fragment")) + } + } + + switch { + case f.BinaryFragment != nil: + applier := NewBinaryApplier(dst, src) + if err := applier.ApplyFragment(f.BinaryFragment); err != nil { + return err + } + return applier.Close() + + case len(f.TextFragments) > 0: + frags := make([]*TextFragment, len(f.TextFragments)) + copy(frags, f.TextFragments) + + sort.Slice(frags, func(i, j int) bool { + return frags[i].OldPosition < frags[j].OldPosition + }) + + // TODO(bkeyes): consider merging overlapping fragments + // right now, the application fails if fragments overlap, but it should be + // possible to precompute the result of applying them in order + + applier := NewTextApplier(dst, src) + for i, frag := range frags { + if err := applier.ApplyFragment(frag); err != nil { + return applyError(err, fragNum(i)) + } + } + return applier.Close() + + default: + // nothing to apply, just copy all the data + _, err := copyFrom(dst, src, 0) + return err + } +} diff --git a/internal/gitdiff/apply_binary.go b/internal/gitdiff/apply_binary.go new file mode 100644 index 0000000..b34772d --- /dev/null +++ b/internal/gitdiff/apply_binary.go @@ -0,0 +1,206 @@ +package gitdiff + +import ( + "errors" + "fmt" + "io" +) + +// BinaryApplier applies binary changes described in a fragment to source data. +// The applier must be closed after use. +type BinaryApplier struct { + dst io.Writer + src io.ReaderAt + + closed bool + dirty bool +} + +// NewBinaryApplier creates an BinaryApplier that reads data from src and +// writes modified data to dst. +func NewBinaryApplier(dst io.Writer, src io.ReaderAt) *BinaryApplier { + a := BinaryApplier{ + dst: dst, + src: src, + } + return &a +} + +// ApplyFragment applies the changes in the fragment f and writes the result to +// dst. ApplyFragment can be called at most once. +// +// If an error occurs while applying, ApplyFragment returns an *ApplyError that +// annotates the error with additional information. If the error is because of +// a conflict between a fragment and the source, the wrapped error will be a +// *Conflict. +func (a *BinaryApplier) ApplyFragment(f *BinaryFragment) error { + if f == nil { + return applyError(errors.New("nil fragment")) + } + if a.closed { + return applyError(errApplierClosed) + } + if a.dirty { + return applyError(errApplyInProgress) + } + + // mark an apply as in progress, even if it fails before making changes + a.dirty = true + + switch f.Method { + case BinaryPatchLiteral: + if _, err := a.dst.Write(f.Data); err != nil { + return applyError(err) + } + case BinaryPatchDelta: + if err := applyBinaryDeltaFragment(a.dst, a.src, f.Data); err != nil { + return applyError(err) + } + default: + return applyError(fmt.Errorf("unsupported binary patch method: %v", f.Method)) + } + return nil +} + +// Close writes any data following the last applied fragment and prevents +// future calls to ApplyFragment. +func (a *BinaryApplier) Close() (err error) { + if a.closed { + return nil + } + + a.closed = true + if !a.dirty { + _, err = copyFrom(a.dst, a.src, 0) + } else { + // do nothing, applying a binary fragment copies all data + } + return err +} + +func applyBinaryDeltaFragment(dst io.Writer, src io.ReaderAt, frag []byte) error { + srcSize, delta := readBinaryDeltaSize(frag) + if err := checkBinarySrcSize(src, srcSize); err != nil { + return err + } + + dstSize, delta := readBinaryDeltaSize(delta) + + for len(delta) > 0 { + op := delta[0] + if op == 0 { + return errors.New("invalid delta opcode 0") + } + + var n int64 + var err error + switch op & 0x80 { + case 0x80: + n, delta, err = applyBinaryDeltaCopy(dst, op, delta[1:], src) + case 0x00: + n, delta, err = applyBinaryDeltaAdd(dst, op, delta[1:]) + } + if err != nil { + return err + } + dstSize -= n + } + + if dstSize != 0 { + return errors.New("corrupt binary delta: insufficient or extra data") + } + return nil +} + +// readBinaryDeltaSize reads a variable length size from a delta-encoded binary +// fragment, returing the size and the unused data. Data is encoded as: +// +// [[1xxxxxxx]...] [0xxxxxxx] +// +// in little-endian order, with 7 bits of the value per byte. +func readBinaryDeltaSize(d []byte) (size int64, rest []byte) { + shift := uint(0) + for i, b := range d { + size |= int64(b&0x7F) << shift + shift += 7 + if b <= 0x7F { + return size, d[i+1:] + } + } + return size, nil +} + +// applyBinaryDeltaAdd applies an add opcode in a delta-encoded binary +// fragment, returning the amount of data written and the usused part of the +// fragment. An add operation takes the form: +// +// [0xxxxxx][[data1]...] +// +// where the lower seven bits of the opcode is the number of data bytes +// following the opcode. See also pack-format.txt in the Git source. +func applyBinaryDeltaAdd(w io.Writer, op byte, delta []byte) (n int64, rest []byte, err error) { + size := int(op) + if len(delta) < size { + return 0, delta, errors.New("corrupt binary delta: incomplete add") + } + _, err = w.Write(delta[:size]) + return int64(size), delta[size:], err +} + +// applyBinaryDeltaCopy applies a copy opcode in a delta-encoded binary +// fragment, returing the amount of data written and the unused part of the +// fragment. A copy operation takes the form: +// +// [1xxxxxxx][offset1][offset2][offset3][offset4][size1][size2][size3] +// +// where the lower seven bits of the opcode determine which non-zero offset and +// size bytes are present in little-endian order: if bit 0 is set, offset1 is +// present, etc. If no offset or size bytes are present, offset is 0 and size +// is 0x10000. See also pack-format.txt in the Git source. +func applyBinaryDeltaCopy(w io.Writer, op byte, delta []byte, src io.ReaderAt) (n int64, rest []byte, err error) { + const defaultSize = 0x10000 + + unpack := func(start, bits uint) (v int64) { + for i := uint(0); i < bits; i++ { + mask := byte(1 << (i + start)) + if op&mask > 0 { + if len(delta) == 0 { + err = errors.New("corrupt binary delta: incomplete copy") + return + } + v |= int64(delta[0]) << (8 * i) + delta = delta[1:] + } + } + return + } + + offset := unpack(0, 4) + size := unpack(4, 3) + if err != nil { + return 0, delta, err + } + if size == 0 { + size = defaultSize + } + + // TODO(bkeyes): consider pooling these buffers + b := make([]byte, size) + if _, err := src.ReadAt(b, offset); err != nil { + return 0, delta, err + } + + _, err = w.Write(b) + return size, delta, err +} + +func checkBinarySrcSize(r io.ReaderAt, size int64) error { + ok, err := isLen(r, size) + if err != nil { + return err + } + if !ok { + return &Conflict{"fragment src size does not match actual src size"} + } + return nil +} diff --git a/internal/gitdiff/apply_text.go b/internal/gitdiff/apply_text.go new file mode 100644 index 0000000..8d0accb --- /dev/null +++ b/internal/gitdiff/apply_text.go @@ -0,0 +1,158 @@ +package gitdiff + +import ( + "errors" + "io" +) + +// TextApplier applies changes described in text fragments to source data. If +// changes are described in multiple fragments, those fragments must be applied +// in order. The applier must be closed after use. +// +// By default, TextApplier operates in "strict" mode, where fragment content +// and positions must exactly match those of the source. +type TextApplier struct { + dst io.Writer + src io.ReaderAt + lineSrc LineReaderAt + nextLine int64 + + closed bool + dirty bool +} + +// NewTextApplier creates a TextApplier that reads data from src and writes +// modified data to dst. If src implements LineReaderAt, it is used directly. +func NewTextApplier(dst io.Writer, src io.ReaderAt) *TextApplier { + a := TextApplier{ + dst: dst, + src: src, + } + + if lineSrc, ok := src.(LineReaderAt); ok { + a.lineSrc = lineSrc + } else { + a.lineSrc = &lineReaderAt{r: src} + } + + return &a +} + +// ApplyFragment applies the changes in the fragment f, writing unwritten data +// before the start of the fragment and any changes from the fragment. If +// multiple text fragments apply to the same content, ApplyFragment must be +// called in order of increasing start position. As a result, each fragment can +// be applied at most once. +// +// If an error occurs while applying, ApplyFragment returns an *ApplyError that +// annotates the error with additional information. If the error is because of +// a conflict between the fragment and the source, the wrapped error will be a +// *Conflict. +func (a *TextApplier) ApplyFragment(f *TextFragment) error { + if a.closed { + return applyError(errApplierClosed) + } + + // mark an apply as in progress, even if it fails before making changes + a.dirty = true + + // application code assumes fragment fields are consistent + if err := f.Validate(); err != nil { + return applyError(err) + } + + // lines are 0-indexed, positions are 1-indexed (but new files have position = 0) + fragStart := f.OldPosition - 1 + if fragStart < 0 { + fragStart = 0 + } + fragEnd := fragStart + f.OldLines + + start := a.nextLine + if fragStart < start { + return applyError(&Conflict{"fragment overlaps with an applied fragment"}) + } + + if f.OldPosition == 0 { + ok, err := isLen(a.src, 0) + if err != nil { + return applyError(err) + } + if !ok { + return applyError(&Conflict{"cannot create new file from non-empty src"}) + } + } + + preimage := make([][]byte, fragEnd-start) + n, err := a.lineSrc.ReadLinesAt(preimage, start) + if err != nil { + // an EOF indicates that source file is shorter than the patch expects, + // which should be reported as a conflict rather than a generic error + if errors.Is(err, io.EOF) { + err = &Conflict{"src has fewer lines than required by fragment"} + } + return applyError(err, lineNum(start+int64(n))) + } + + // copy leading data before the fragment starts + for i, line := range preimage[:fragStart-start] { + if _, err := a.dst.Write(line); err != nil { + a.nextLine = start + int64(i) + return applyError(err, lineNum(a.nextLine)) + } + } + preimage = preimage[fragStart-start:] + + // apply the changes in the fragment + used := int64(0) + for i, line := range f.Lines { + if err := applyTextLine(a.dst, line, preimage, used); err != nil { + a.nextLine = fragStart + used + return applyError(err, lineNum(a.nextLine), fragLineNum(i)) + } + if line.Old() { + used++ + } + } + a.nextLine = fragStart + used + + // new position of +0,0 mean a full delete, so check for leftovers + if f.NewPosition == 0 && f.NewLines == 0 { + var b [1][]byte + n, err := a.lineSrc.ReadLinesAt(b[:], a.nextLine) + if err != nil && err != io.EOF { + return applyError(err, lineNum(a.nextLine)) + } + if n > 0 { + return applyError(&Conflict{"src still has content after full delete"}, lineNum(a.nextLine)) + } + } + + return nil +} + +func applyTextLine(dst io.Writer, line Line, preimage [][]byte, i int64) (err error) { + if line.Old() && string(preimage[i]) != line.Line { + return &Conflict{"fragment line does not match src line"} + } + if line.New() { + _, err = io.WriteString(dst, line.Line) + } + return err +} + +// Close writes any data following the last applied fragment and prevents +// future calls to ApplyFragment. +func (a *TextApplier) Close() (err error) { + if a.closed { + return nil + } + + a.closed = true + if !a.dirty { + _, err = copyFrom(a.dst, a.src, 0) + } else { + _, err = copyLinesFrom(a.dst, a.lineSrc, a.nextLine) + } + return err +} diff --git a/internal/gitdiff/base85.go b/internal/gitdiff/base85.go new file mode 100644 index 0000000..86db117 --- /dev/null +++ b/internal/gitdiff/base85.go @@ -0,0 +1,91 @@ +package gitdiff + +import ( + "fmt" +) + +var ( + b85Table map[byte]byte + b85Alpha = []byte( + "0123456789" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "!#$%&()*+-;<=>?@^_`{|}~", + ) +) + +func init() { + b85Table = make(map[byte]byte) + for i, c := range b85Alpha { + b85Table[c] = byte(i) + } +} + +// base85Decode decodes Base85-encoded data from src into dst. It uses the +// alphabet defined by base85.c in the Git source tree. src must contain at +// least len(dst) bytes of encoded data. +func base85Decode(dst, src []byte) error { + var v uint32 + var n, ndst int + for i, b := range src { + if b, ok := b85Table[b]; ok { + v = 85*v + uint32(b) + n++ + } else { + return fmt.Errorf("invalid base85 byte at index %d: 0x%X", i, src[i]) + } + if n == 5 { + rem := len(dst) - ndst + for j := 0; j < 4 && j < rem; j++ { + dst[ndst] = byte(v >> 24) + ndst++ + v <<= 8 + } + v = 0 + n = 0 + } + } + if n > 0 { + return fmt.Errorf("base85 data terminated by underpadded sequence") + } + if ndst < len(dst) { + return fmt.Errorf("base85 data underrun: %d < %d", ndst, len(dst)) + } + return nil +} + +// base85Encode encodes src in Base85, writing the result to dst. It uses the +// alphabet defined by base85.c in the Git source tree. +func base85Encode(dst, src []byte) { + var di, si int + + encode := func(v uint32) { + dst[di+0] = b85Alpha[(v/(85*85*85*85))%85] + dst[di+1] = b85Alpha[(v/(85*85*85))%85] + dst[di+2] = b85Alpha[(v/(85*85))%85] + dst[di+3] = b85Alpha[(v/85)%85] + dst[di+4] = b85Alpha[v%85] + } + + n := (len(src) / 4) * 4 + for si < n { + encode(uint32(src[si+0])<<24 | uint32(src[si+1])<<16 | uint32(src[si+2])<<8 | uint32(src[si+3])) + si += 4 + di += 5 + } + + var v uint32 + switch len(src) - si { + case 3: + v |= uint32(src[si+2]) << 8 + fallthrough + case 2: + v |= uint32(src[si+1]) << 16 + fallthrough + case 1: + v |= uint32(src[si+0]) << 24 + encode(v) + } +} + +// base85Len returns the length of n bytes of Base85 encoded data. +func base85Len(n int) int { + return (n + 3) / 4 * 5 +} diff --git a/internal/gitdiff/binary.go b/internal/gitdiff/binary.go new file mode 100644 index 0000000..282e323 --- /dev/null +++ b/internal/gitdiff/binary.go @@ -0,0 +1,186 @@ +package gitdiff + +import ( + "bytes" + "compress/zlib" + "fmt" + "io" + "io/ioutil" + "strconv" + "strings" +) + +func (p *parser) ParseBinaryFragments(f *File) (n int, err error) { + isBinary, hasData, err := p.ParseBinaryMarker() + if err != nil || !isBinary { + return 0, err + } + + f.IsBinary = true + if !hasData { + return 0, nil + } + + forward, err := p.ParseBinaryFragmentHeader() + if err != nil { + return 0, err + } + if forward == nil { + return 0, p.Errorf(0, "missing data for binary patch") + } + if err := p.ParseBinaryChunk(forward); err != nil { + return 0, err + } + f.BinaryFragment = forward + + // valid for reverse to not exist, but it must be valid if present + reverse, err := p.ParseBinaryFragmentHeader() + if err != nil { + return 1, err + } + if reverse == nil { + return 1, nil + } + if err := p.ParseBinaryChunk(reverse); err != nil { + return 1, err + } + f.ReverseBinaryFragment = reverse + + return 1, nil +} + +func (p *parser) ParseBinaryMarker() (isBinary bool, hasData bool, err error) { + line := p.Line(0) + switch { + case line == "GIT binary patch\n": + hasData = true + case isBinaryNoDataMarker(line): + default: + return false, false, nil + } + + if err = p.Next(); err != nil && err != io.EOF { + return false, false, err + } + return true, hasData, nil +} + +func isBinaryNoDataMarker(line string) bool { + if strings.HasSuffix(line, " differ\n") { + return strings.HasPrefix(line, "Binary files ") || strings.HasPrefix(line, "Files ") + } + return false +} + +func (p *parser) ParseBinaryFragmentHeader() (*BinaryFragment, error) { + parts := strings.SplitN(strings.TrimSuffix(p.Line(0), "\n"), " ", 2) + if len(parts) < 2 { + return nil, nil + } + + frag := &BinaryFragment{} + switch parts[0] { + case "delta": + frag.Method = BinaryPatchDelta + case "literal": + frag.Method = BinaryPatchLiteral + default: + return nil, nil + } + + var err error + if frag.Size, err = strconv.ParseInt(parts[1], 10, 64); err != nil { + nerr := err.(*strconv.NumError) + return nil, p.Errorf(0, "binary patch: invalid size: %v", nerr.Err) + } + + if err := p.Next(); err != nil && err != io.EOF { + return nil, err + } + return frag, nil +} + +func (p *parser) ParseBinaryChunk(frag *BinaryFragment) error { + // Binary fragments are encoded as a series of base85 encoded lines. Each + // line starts with a character in [A-Za-z] giving the number of bytes on + // the line, where A = 1 and z = 52, and ends with a newline character. + // + // The base85 encoding means each line is a multiple of 5 characters + 2 + // additional characters for the length byte and the newline. The fragment + // ends with a blank line. + const ( + shortestValidLine = "A00000\n" + maxBytesPerLine = 52 + ) + + var data bytes.Buffer + buf := make([]byte, maxBytesPerLine) + for { + line := p.Line(0) + if line == "\n" { + break + } + if len(line) < len(shortestValidLine) || (len(line)-2)%5 != 0 { + return p.Errorf(0, "binary patch: corrupt data line") + } + + byteCount, seq := int(line[0]), line[1:len(line)-1] + switch { + case 'A' <= byteCount && byteCount <= 'Z': + byteCount = byteCount - 'A' + 1 + case 'a' <= byteCount && byteCount <= 'z': + byteCount = byteCount - 'a' + 27 + default: + return p.Errorf(0, "binary patch: invalid length byte") + } + + // base85 encodes every 4 bytes into 5 characters, with up to 3 bytes of end padding + maxByteCount := len(seq) / 5 * 4 + if byteCount > maxByteCount || byteCount < maxByteCount-3 { + return p.Errorf(0, "binary patch: incorrect byte count") + } + + if err := base85Decode(buf[:byteCount], []byte(seq)); err != nil { + return p.Errorf(0, "binary patch: %v", err) + } + data.Write(buf[:byteCount]) + + if err := p.Next(); err != nil { + if err == io.EOF { + return p.Errorf(0, "binary patch: unexpected EOF") + } + return err + } + } + + if err := inflateBinaryChunk(frag, &data); err != nil { + return p.Errorf(0, "binary patch: %v", err) + } + + // consume the empty line that ended the fragment + if err := p.Next(); err != nil && err != io.EOF { + return err + } + return nil +} + +func inflateBinaryChunk(frag *BinaryFragment, r io.Reader) error { + zr, err := zlib.NewReader(r) + if err != nil { + return err + } + + data, err := ioutil.ReadAll(zr) + if err != nil { + return err + } + if err := zr.Close(); err != nil { + return err + } + + if int64(len(data)) != frag.Size { + return fmt.Errorf("%d byte fragment inflated to %d", frag.Size, len(data)) + } + frag.Data = data + return nil +} diff --git a/internal/gitdiff/file_header.go b/internal/gitdiff/file_header.go new file mode 100644 index 0000000..7ae4bc9 --- /dev/null +++ b/internal/gitdiff/file_header.go @@ -0,0 +1,546 @@ +package gitdiff + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "time" +) + +const ( + devNull = "/dev/null" +) + +// ParseNextFileHeader finds and parses the next file header in the stream. If +// a header is found, it returns a file and all input before the header. It +// returns nil if no headers are found before the end of the input. +func (p *parser) ParseNextFileHeader() (*File, string, error) { + var preamble strings.Builder + var file *File + for { + // check for disconnected fragment headers (corrupt patch) + frag, err := p.ParseTextFragmentHeader() + if err != nil { + // not a valid header, nothing to worry about + goto NextLine + } + if frag != nil { + return nil, "", p.Errorf(-1, "patch fragment without file header: %s", frag.Header()) + } + + // check for a git-generated patch + file, err = p.ParseGitFileHeader() + if err != nil { + return nil, "", err + } + if file != nil { + return file, preamble.String(), nil + } + + // check for a "traditional" patch + file, err = p.ParseTraditionalFileHeader() + if err != nil { + return nil, "", err + } + if file != nil { + return file, preamble.String(), nil + } + + NextLine: + preamble.WriteString(p.Line(0)) + if err := p.Next(); err != nil { + if err == io.EOF { + break + } + return nil, "", err + } + } + return nil, preamble.String(), nil +} + +func (p *parser) ParseGitFileHeader() (*File, error) { + const prefix = "diff --git " + + if !strings.HasPrefix(p.Line(0), prefix) { + return nil, nil + } + header := p.Line(0)[len(prefix):] + + defaultName, err := parseGitHeaderName(header) + if err != nil { + return nil, p.Errorf(0, "git file header: %v", err) + } + + f := &File{} + for { + end, err := parseGitHeaderData(f, p.Line(1), defaultName) + if err != nil { + return nil, p.Errorf(1, "git file header: %v", err) + } + + if err := p.Next(); err != nil { + if err == io.EOF { + break + } + return nil, err + } + + if end { + break + } + } + + if f.OldName == "" && f.NewName == "" { + if defaultName == "" { + return nil, p.Errorf(0, "git file header: missing filename information") + } + f.OldName = defaultName + f.NewName = defaultName + } + + if (f.NewName == "" && !f.IsDelete) || (f.OldName == "" && !f.IsNew) { + return nil, p.Errorf(0, "git file header: missing filename information") + } + + return f, nil +} + +func (p *parser) ParseTraditionalFileHeader() (*File, error) { + const shortestValidFragHeader = "@@ -1 +1 @@\n" + const ( + oldPrefix = "--- " + newPrefix = "+++ " + ) + + oldLine, newLine := p.Line(0), p.Line(1) + + if !strings.HasPrefix(oldLine, oldPrefix) || !strings.HasPrefix(newLine, newPrefix) { + return nil, nil + } + // heuristic: only a file header if followed by a (probable) fragment header + if len(p.Line(2)) < len(shortestValidFragHeader) || !strings.HasPrefix(p.Line(2), "@@ -") { + return nil, nil + } + + // advance past the first two lines so parser is after the header + // no EOF check needed because we know there are >=3 valid lines + if err := p.Next(); err != nil { + return nil, err + } + if err := p.Next(); err != nil { + return nil, err + } + + oldName, _, err := parseName(oldLine[len(oldPrefix):], '\t', 0) + if err != nil { + return nil, p.Errorf(0, "file header: %v", err) + } + + newName, _, err := parseName(newLine[len(newPrefix):], '\t', 0) + if err != nil { + return nil, p.Errorf(1, "file header: %v", err) + } + + f := &File{} + switch { + case oldName == devNull || hasEpochTimestamp(oldLine): + f.IsNew = true + f.NewName = newName + case newName == devNull || hasEpochTimestamp(newLine): + f.IsDelete = true + f.OldName = oldName + default: + // if old name is a prefix of new name, use that instead + // this avoids picking variants like "file.bak" or "file~" + if strings.HasPrefix(newName, oldName) { + f.OldName = oldName + f.NewName = oldName + } else { + f.OldName = newName + f.NewName = newName + } + } + + return f, nil +} + +// parseGitHeaderName extracts a default file name from the Git file header +// line. This is required for mode-only changes and creation/deletion of empty +// files. Other types of patch include the file name(s) in the header data. +// If the names in the header do not match because the patch is a rename, +// return an empty default name. +func parseGitHeaderName(header string) (string, error) { + header = strings.TrimSuffix(header, "\n") + if len(header) == 0 { + return "", nil + } + + var err error + var first, second string + + // there are 4 cases to account for: + // + // 1) unquoted unquoted + // 2) unquoted "quoted" + // 3) "quoted" unquoted + // 4) "quoted" "quoted" + // + quote := strings.IndexByte(header, '"') + switch { + case quote < 0: + // case 1 + first = header + + case quote > 0: + // case 2 + first = header[:quote-1] + if !isSpace(header[quote-1]) { + return "", fmt.Errorf("missing separator") + } + + second, _, err = parseQuotedName(header[quote:]) + if err != nil { + return "", err + } + + case quote == 0: + // case 3 or case 4 + var n int + first, n, err = parseQuotedName(header) + if err != nil { + return "", err + } + + // git accepts multiple spaces after a quoted name, but not after an + // unquoted name, since the name might end with one or more spaces + for n < len(header) && isSpace(header[n]) { + n++ + } + if n == len(header) { + return "", nil + } + + if header[n] == '"' { + second, _, err = parseQuotedName(header[n:]) + if err != nil { + return "", err + } + } else { + second = header[n:] + } + } + + first = trimTreePrefix(first, 1) + if second != "" { + if first == trimTreePrefix(second, 1) { + return first, nil + } + return "", nil + } + + // at this point, both names are unquoted (case 1) + // since names may contain spaces, we can't use a known separator + // instead, look for a split that produces two equal names + + for i := 0; i < len(first)-1; i++ { + if !isSpace(first[i]) { + continue + } + second = trimTreePrefix(first[i+1:], 1) + if name := first[:i]; name == second { + return name, nil + } + } + return "", nil +} + +// parseGitHeaderData parses a single line of metadata from a Git file header. +// It returns true when header parsing is complete; in that case, line was the +// first line of non-header content. +func parseGitHeaderData(f *File, line, defaultName string) (end bool, err error) { + if len(line) > 0 && line[len(line)-1] == '\n' { + line = line[:len(line)-1] + } + + for _, hdr := range []struct { + prefix string + end bool + parse func(*File, string, string) error + }{ + {"@@ -", true, nil}, + {"--- ", false, parseGitHeaderOldName}, + {"+++ ", false, parseGitHeaderNewName}, + {"old mode ", false, parseGitHeaderOldMode}, + {"new mode ", false, parseGitHeaderNewMode}, + {"deleted file mode ", false, parseGitHeaderDeletedMode}, + {"new file mode ", false, parseGitHeaderCreatedMode}, + {"copy from ", false, parseGitHeaderCopyFrom}, + {"copy to ", false, parseGitHeaderCopyTo}, + {"rename old ", false, parseGitHeaderRenameFrom}, + {"rename new ", false, parseGitHeaderRenameTo}, + {"rename from ", false, parseGitHeaderRenameFrom}, + {"rename to ", false, parseGitHeaderRenameTo}, + {"similarity index ", false, parseGitHeaderScore}, + {"dissimilarity index ", false, parseGitHeaderScore}, + {"index ", false, parseGitHeaderIndex}, + } { + if strings.HasPrefix(line, hdr.prefix) { + if hdr.parse != nil { + err = hdr.parse(f, line[len(hdr.prefix):], defaultName) + } + return hdr.end, err + } + } + + // unknown line indicates the end of the header + // this usually happens if the diff is empty + return true, nil +} + +func parseGitHeaderOldName(f *File, line, defaultName string) error { + name, _, err := parseName(line, '\t', 1) + if err != nil { + return err + } + if f.OldName == "" && !f.IsNew { + f.OldName = name + return nil + } + return verifyGitHeaderName(name, f.OldName, f.IsNew, "old") +} + +func parseGitHeaderNewName(f *File, line, defaultName string) error { + name, _, err := parseName(line, '\t', 1) + if err != nil { + return err + } + if f.NewName == "" && !f.IsDelete { + f.NewName = name + return nil + } + return verifyGitHeaderName(name, f.NewName, f.IsDelete, "new") +} + +func parseGitHeaderOldMode(f *File, line, defaultName string) (err error) { + f.OldMode, err = parseMode(strings.TrimSpace(line)) + return +} + +func parseGitHeaderNewMode(f *File, line, defaultName string) (err error) { + f.NewMode, err = parseMode(strings.TrimSpace(line)) + return +} + +func parseGitHeaderDeletedMode(f *File, line, defaultName string) error { + f.IsDelete = true + f.OldName = defaultName + return parseGitHeaderOldMode(f, line, defaultName) +} + +func parseGitHeaderCreatedMode(f *File, line, defaultName string) error { + f.IsNew = true + f.NewName = defaultName + return parseGitHeaderNewMode(f, line, defaultName) +} + +func parseGitHeaderCopyFrom(f *File, line, defaultName string) (err error) { + f.IsCopy = true + f.OldName, _, err = parseName(line, 0, 0) + return +} + +func parseGitHeaderCopyTo(f *File, line, defaultName string) (err error) { + f.IsCopy = true + f.NewName, _, err = parseName(line, 0, 0) + return +} + +func parseGitHeaderRenameFrom(f *File, line, defaultName string) (err error) { + f.IsRename = true + f.OldName, _, err = parseName(line, 0, 0) + return +} + +func parseGitHeaderRenameTo(f *File, line, defaultName string) (err error) { + f.IsRename = true + f.NewName, _, err = parseName(line, 0, 0) + return +} + +func parseGitHeaderScore(f *File, line, defaultName string) error { + score, err := strconv.ParseInt(strings.TrimSuffix(line, "%"), 10, 32) + if err != nil { + nerr := err.(*strconv.NumError) + return fmt.Errorf("invalid score line: %v", nerr.Err) + } + if score <= 100 { + f.Score = int(score) + } + return nil +} + +func parseGitHeaderIndex(f *File, line, defaultName string) error { + const sep = ".." + + // note that git stops parsing if the OIDs are too long to be valid + // checking this requires knowing if the repository uses SHA1 or SHA256 + // hashes, which we don't know, so we just skip that check + + parts := strings.SplitN(line, " ", 2) + oids := strings.SplitN(parts[0], sep, 2) + + if len(oids) < 2 { + return fmt.Errorf("invalid index line: missing %q", sep) + } + f.OldOIDPrefix, f.NewOIDPrefix = oids[0], oids[1] + + if len(parts) > 1 { + return parseGitHeaderOldMode(f, parts[1], defaultName) + } + return nil +} + +func parseMode(s string) (os.FileMode, error) { + mode, err := strconv.ParseInt(s, 8, 32) + if err != nil { + nerr := err.(*strconv.NumError) + return os.FileMode(0), fmt.Errorf("invalid mode line: %v", nerr.Err) + } + return os.FileMode(mode), nil +} + +// parseName extracts a file name from the start of a string and returns the +// name and the index of the first character after the name. If the name is +// unquoted and term is non-zero, parsing stops at the first occurrence of +// term. +// +// If the name is exactly "/dev/null", no further processing occurs. Otherwise, +// if dropPrefix is greater than zero, that number of prefix components +// separated by forward slashes are dropped from the name and any duplicate +// slashes are collapsed. +func parseName(s string, term byte, dropPrefix int) (name string, n int, err error) { + if len(s) > 0 && s[0] == '"' { + name, n, err = parseQuotedName(s) + } else { + name, n, err = parseUnquotedName(s, term) + } + if err != nil { + return "", 0, err + } + if name == devNull { + return name, n, nil + } + return cleanName(name, dropPrefix), n, nil +} + +func parseQuotedName(s string) (name string, n int, err error) { + for n = 1; n < len(s); n++ { + if s[n] == '"' && s[n-1] != '\\' { + n++ + break + } + } + if n == 2 { + return "", 0, fmt.Errorf("missing name") + } + if name, err = strconv.Unquote(s[:n]); err != nil { + return "", 0, err + } + return name, n, err +} + +func parseUnquotedName(s string, term byte) (name string, n int, err error) { + for n = 0; n < len(s); n++ { + if s[n] == '\n' { + break + } + if term > 0 && s[n] == term { + break + } + } + if n == 0 { + return "", 0, fmt.Errorf("missing name") + } + return s[:n], n, nil +} + +// verifyGitHeaderName checks a parsed name against state set by previous lines +func verifyGitHeaderName(parsed, existing string, isNull bool, side string) error { + if existing != "" { + if isNull { + return fmt.Errorf("expected %s, but filename is set to %s", devNull, existing) + } + if existing != parsed { + return fmt.Errorf("inconsistent %s filename", side) + } + } + if isNull && parsed != devNull { + return fmt.Errorf("expected %s", devNull) + } + return nil +} + +// cleanName removes double slashes and drops prefix segments. +func cleanName(name string, drop int) string { + var b strings.Builder + for i := 0; i < len(name); i++ { + if name[i] == '/' { + if i < len(name)-1 && name[i+1] == '/' { + continue + } + if drop > 0 { + drop-- + b.Reset() + continue + } + } + b.WriteByte(name[i]) + } + return b.String() +} + +// trimTreePrefix removes up to n leading directory components from name. +func trimTreePrefix(name string, n int) string { + i := 0 + for ; i < len(name) && n > 0; i++ { + if name[i] == '/' { + n-- + } + } + return name[i:] +} + +// hasEpochTimestamp returns true if the string ends with a POSIX-formatted +// timestamp for the UNIX epoch after a tab character. According to git, this +// is used by GNU diff to mark creations and deletions. +func hasEpochTimestamp(s string) bool { + const posixTimeLayout = "2006-01-02 15:04:05.9 -0700" + + start := strings.IndexRune(s, '\t') + if start < 0 { + return false + } + + ts := strings.TrimSuffix(s[start+1:], "\n") + + // a valid timestamp can have optional ':' in zone specifier + // remove that if it exists so we have a single format + if len(ts) >= 3 && ts[len(ts)-3] == ':' { + ts = ts[:len(ts)-3] + ts[len(ts)-2:] + } + + t, err := time.Parse(posixTimeLayout, ts) + if err != nil { + return false + } + if !t.Equal(time.Unix(0, 0)) { + return false + } + return true +} + +func isSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' +} diff --git a/internal/gitdiff/format.go b/internal/gitdiff/format.go new file mode 100644 index 0000000..d97aba9 --- /dev/null +++ b/internal/gitdiff/format.go @@ -0,0 +1,281 @@ +package gitdiff + +import ( + "bytes" + "compress/zlib" + "fmt" + "io" + "strconv" +) + +type formatter struct { + w io.Writer + err error +} + +func newFormatter(w io.Writer) *formatter { + return &formatter{w: w} +} + +func (fm *formatter) Write(p []byte) (int, error) { + if fm.err != nil { + return len(p), nil + } + if _, err := fm.w.Write(p); err != nil { + fm.err = err + } + return len(p), nil +} + +func (fm *formatter) WriteString(s string) (int, error) { + fm.Write([]byte(s)) + return len(s), nil +} + +func (fm *formatter) WriteByte(c byte) error { + fm.Write([]byte{c}) + return nil +} + +func (fm *formatter) WriteQuotedName(s string) { + qpos := 0 + for i := 0; i < len(s); i++ { + ch := s[i] + if q, quoted := quoteByte(ch); quoted { + if qpos == 0 { + fm.WriteByte('"') + } + fm.WriteString(s[qpos:i]) + fm.Write(q) + qpos = i + 1 + } + } + fm.WriteString(s[qpos:]) + if qpos > 0 { + fm.WriteByte('"') + } +} + +var quoteEscapeTable = map[byte]byte{ + '\a': 'a', + '\b': 'b', + '\t': 't', + '\n': 'n', + '\v': 'v', + '\f': 'f', + '\r': 'r', + '"': '"', + '\\': '\\', +} + +func quoteByte(b byte) ([]byte, bool) { + if q, ok := quoteEscapeTable[b]; ok { + return []byte{'\\', q}, true + } + if b < 0x20 || b >= 0x7F { + return []byte{ + '\\', + '0' + (b>>6)&0o3, + '0' + (b>>3)&0o7, + '0' + (b>>0)&0o7, + }, true + } + return nil, false +} + +func (fm *formatter) FormatFile(f *File) { + fm.WriteString("diff --git ") + + var aName, bName string + switch { + case f.OldName == "": + aName = f.NewName + bName = f.NewName + + case f.NewName == "": + aName = f.OldName + bName = f.OldName + + default: + aName = f.OldName + bName = f.NewName + } + + fm.WriteQuotedName("a/" + aName) + fm.WriteByte(' ') + fm.WriteQuotedName("b/" + bName) + fm.WriteByte('\n') + + if f.OldMode != 0 { + if f.IsDelete { + fmt.Fprintf(fm, "deleted file mode %o\n", f.OldMode) + } else if f.NewMode != 0 { + fmt.Fprintf(fm, "old mode %o\n", f.OldMode) + } + } + + if f.NewMode != 0 { + if f.IsNew { + fmt.Fprintf(fm, "new file mode %o\n", f.NewMode) + } else if f.OldMode != 0 { + fmt.Fprintf(fm, "new mode %o\n", f.NewMode) + } + } + + if f.Score > 0 { + if f.IsCopy || f.IsRename { + fmt.Fprintf(fm, "similarity index %d%%\n", f.Score) + } else { + fmt.Fprintf(fm, "dissimilarity index %d%%\n", f.Score) + } + } + + if f.IsCopy { + if f.OldName != "" { + fm.WriteString("copy from ") + fm.WriteQuotedName(f.OldName) + fm.WriteByte('\n') + } + if f.NewName != "" { + fm.WriteString("copy to ") + fm.WriteQuotedName(f.NewName) + fm.WriteByte('\n') + } + } + + if f.IsRename { + if f.OldName != "" { + fm.WriteString("rename from ") + fm.WriteQuotedName(f.OldName) + fm.WriteByte('\n') + } + if f.NewName != "" { + fm.WriteString("rename to ") + fm.WriteQuotedName(f.NewName) + fm.WriteByte('\n') + } + } + + if f.OldOIDPrefix != "" && f.NewOIDPrefix != "" { + fmt.Fprintf(fm, "index %s..%s", f.OldOIDPrefix, f.NewOIDPrefix) + + // Mode is only included on the index line when it is not changing + if f.OldMode != 0 && ((f.NewMode == 0 && !f.IsDelete) || f.OldMode == f.NewMode) { + fmt.Fprintf(fm, " %o", f.OldMode) + } + + fm.WriteByte('\n') + } + + if f.IsBinary { + if f.BinaryFragment == nil { + fm.WriteString("Binary files ") + fm.WriteQuotedName("a/" + aName) + fm.WriteString(" and ") + fm.WriteQuotedName("b/" + bName) + fm.WriteString(" differ\n") + } else { + fm.WriteString("GIT binary patch\n") + fm.FormatBinaryFragment(f.BinaryFragment) + if f.ReverseBinaryFragment != nil { + fm.FormatBinaryFragment(f.ReverseBinaryFragment) + } + } + } + + // The "---" and "+++" lines only appear for text patches with fragments + if len(f.TextFragments) > 0 { + fm.WriteString("--- ") + if f.OldName == "" { + fm.WriteString("/dev/null") + } else { + fm.WriteQuotedName("a/" + f.OldName) + } + fm.WriteByte('\n') + + fm.WriteString("+++ ") + if f.NewName == "" { + fm.WriteString("/dev/null") + } else { + fm.WriteQuotedName("b/" + f.NewName) + } + fm.WriteByte('\n') + + for _, frag := range f.TextFragments { + fm.FormatTextFragment(frag) + } + } +} + +func (fm *formatter) FormatTextFragment(f *TextFragment) { + fm.FormatTextFragmentHeader(f) + fm.WriteByte('\n') + + for _, line := range f.Lines { + fm.WriteString(line.Op.String()) + fm.WriteString(line.Line) + if line.NoEOL() { + fm.WriteString("\n\\ No newline at end of file\n") + } + } +} + +func (fm *formatter) FormatTextFragmentHeader(f *TextFragment) { + fmt.Fprintf(fm, "@@ -%d,%d +%d,%d @@", f.OldPosition, f.OldLines, f.NewPosition, f.NewLines) + if f.Comment != "" { + fm.WriteByte(' ') + fm.WriteString(f.Comment) + } +} + +func (fm *formatter) FormatBinaryFragment(f *BinaryFragment) { + const ( + maxBytesPerLine = 52 + ) + + switch f.Method { + case BinaryPatchDelta: + fm.WriteString("delta ") + case BinaryPatchLiteral: + fm.WriteString("literal ") + } + fm.Write(strconv.AppendInt(nil, f.Size, 10)) + fm.WriteByte('\n') + + data := deflateBinaryChunk(f.Data) + n := (len(data) / maxBytesPerLine) * maxBytesPerLine + + buf := make([]byte, base85Len(maxBytesPerLine)) + for i := 0; i < n; i += maxBytesPerLine { + base85Encode(buf, data[i:i+maxBytesPerLine]) + fm.WriteByte('z') + fm.Write(buf) + fm.WriteByte('\n') + } + if remainder := len(data) - n; remainder > 0 { + buf = buf[0:base85Len(remainder)] + + sizeChar := byte(remainder) + if remainder <= 26 { + sizeChar = 'A' + sizeChar - 1 + } else { + sizeChar = 'a' + sizeChar - 27 + } + + base85Encode(buf, data[n:]) + fm.WriteByte(sizeChar) + fm.Write(buf) + fm.WriteByte('\n') + } + fm.WriteByte('\n') +} + +func deflateBinaryChunk(data []byte) []byte { + var b bytes.Buffer + + zw := zlib.NewWriter(&b) + _, _ = zw.Write(data) + _ = zw.Close() + + return b.Bytes() +} diff --git a/internal/gitdiff/gitdiff.go b/internal/gitdiff/gitdiff.go new file mode 100644 index 0000000..5365645 --- /dev/null +++ b/internal/gitdiff/gitdiff.go @@ -0,0 +1,230 @@ +package gitdiff + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// File describes changes to a single file. It can be either a text file or a +// binary file. +type File struct { + OldName string + NewName string + + IsNew bool + IsDelete bool + IsCopy bool + IsRename bool + + OldMode os.FileMode + NewMode os.FileMode + + OldOIDPrefix string + NewOIDPrefix string + Score int + + // TextFragments contains the fragments describing changes to a text file. It + // may be empty if the file is empty or if only the mode changes. + TextFragments []*TextFragment + + // IsBinary is true if the file is a binary file. If the patch includes + // binary data, BinaryFragment will be non-nil and describe the changes to + // the data. If the patch is reversible, ReverseBinaryFragment will also be + // non-nil and describe the changes needed to restore the original file + // after applying the changes in BinaryFragment. + IsBinary bool + BinaryFragment *BinaryFragment + ReverseBinaryFragment *BinaryFragment +} + +// String returns a git diff representation of this file. The value can be +// parsed by this library to obtain the same File, but may not be the same as +// the original input. +func (f *File) String() string { + var diff strings.Builder + newFormatter(&diff).FormatFile(f) + return diff.String() +} + +// TextFragment describes changed lines starting at a specific line in a text file. +type TextFragment struct { + Comment string + + OldPosition int64 + OldLines int64 + + NewPosition int64 + NewLines int64 + + LinesAdded int64 + LinesDeleted int64 + + LeadingContext int64 + TrailingContext int64 + + Lines []Line +} + +// String returns a git diff format of this fragment. See [File.String] for +// more details on this format. +func (f *TextFragment) String() string { + var diff strings.Builder + newFormatter(&diff).FormatTextFragment(f) + return diff.String() +} + +// Header returns a git diff header of this fragment. See [File.String] for +// more details on this format. +func (f *TextFragment) Header() string { + var hdr strings.Builder + newFormatter(&hdr).FormatTextFragmentHeader(f) + return hdr.String() +} + +// Validate checks that the fragment is self-consistent and appliable. Validate +// returns an error if and only if the fragment is invalid. +func (f *TextFragment) Validate() error { + if f == nil { + return errors.New("nil fragment") + } + + var ( + oldLines, newLines int64 + leadingContext, trailingContext int64 + contextLines, addedLines, deletedLines int64 + ) + + // count the types of lines in the fragment content + for i, line := range f.Lines { + switch line.Op { + case OpContext: + oldLines++ + newLines++ + contextLines++ + if addedLines == 0 && deletedLines == 0 { + leadingContext++ + } else { + trailingContext++ + } + case OpAdd: + newLines++ + addedLines++ + trailingContext = 0 + case OpDelete: + oldLines++ + deletedLines++ + trailingContext = 0 + default: + return fmt.Errorf("unknown operator %q on line %d", line.Op, i+1) + } + } + + // check the actual counts against the reported counts + if oldLines != f.OldLines { + return lineCountErr("old", oldLines, f.OldLines) + } + if newLines != f.NewLines { + return lineCountErr("new", newLines, f.NewLines) + } + if leadingContext != f.LeadingContext { + return lineCountErr("leading context", leadingContext, f.LeadingContext) + } + if trailingContext != f.TrailingContext { + return lineCountErr("trailing context", trailingContext, f.TrailingContext) + } + if addedLines != f.LinesAdded { + return lineCountErr("added", addedLines, f.LinesAdded) + } + if deletedLines != f.LinesDeleted { + return lineCountErr("deleted", deletedLines, f.LinesDeleted) + } + + // if a file is being created, it can only contain additions + if f.OldPosition == 0 && f.OldLines != 0 { + return errors.New("file creation fragment contains context or deletion lines") + } + + return nil +} + +func lineCountErr(kind string, actual, reported int64) error { + return fmt.Errorf("fragment contains %d %s lines but reports %d", actual, kind, reported) +} + +// Line is a line in a text fragment. +type Line struct { + Op LineOp + Line string +} + +func (fl Line) String() string { + return fl.Op.String() + fl.Line +} + +// Old returns true if the line appears in the old content of the fragment. +func (fl Line) Old() bool { + return fl.Op == OpContext || fl.Op == OpDelete +} + +// New returns true if the line appears in the new content of the fragment. +func (fl Line) New() bool { + return fl.Op == OpContext || fl.Op == OpAdd +} + +// NoEOL returns true if the line is missing a trailing newline character. +func (fl Line) NoEOL() bool { + return len(fl.Line) == 0 || fl.Line[len(fl.Line)-1] != '\n' +} + +// LineOp describes the type of a text fragment line: context, added, or removed. +type LineOp int + +const ( + // OpContext indicates a context line + OpContext LineOp = iota + // OpDelete indicates a deleted line + OpDelete + // OpAdd indicates an added line + OpAdd +) + +func (op LineOp) String() string { + switch op { + case OpContext: + return " " + case OpDelete: + return "-" + case OpAdd: + return "+" + } + return "?" +} + +// BinaryFragment describes changes to a binary file. +type BinaryFragment struct { + Method BinaryPatchMethod + Size int64 + Data []byte +} + +// BinaryPatchMethod is the method used to create and apply the binary patch. +type BinaryPatchMethod int + +const ( + // BinaryPatchDelta indicates the data uses Git's packfile encoding + BinaryPatchDelta BinaryPatchMethod = iota + // BinaryPatchLiteral indicates the data is the exact file content + BinaryPatchLiteral +) + +// String returns a git diff format of this fragment. Due to differences in +// zlib implementation between Go and Git, encoded binary data in the result +// will likely differ from what Git produces for the same input. See +// [File.String] for more details on this format. +func (f *BinaryFragment) String() string { + var diff strings.Builder + newFormatter(&diff).FormatBinaryFragment(f) + return diff.String() +} diff --git a/internal/gitdiff/io.go b/internal/gitdiff/io.go new file mode 100644 index 0000000..8143238 --- /dev/null +++ b/internal/gitdiff/io.go @@ -0,0 +1,220 @@ +package gitdiff + +import ( + "errors" + "io" +) + +const ( + byteBufferSize = 32 * 1024 // from io.Copy + lineBufferSize = 32 + indexBufferSize = 1024 +) + +// LineReaderAt is the interface that wraps the ReadLinesAt method. +// +// ReadLinesAt reads len(lines) into lines starting at line offset. It returns +// the number of lines read (0 <= n <= len(lines)) and any error encountered. +// Line numbers are zero-indexed. +// +// If n < len(lines), ReadLinesAt returns a non-nil error explaining why more +// lines were not returned. +// +// Lines read by ReadLinesAt include the newline character. The last line does +// not have a final newline character if the input ends without one. +type LineReaderAt interface { + ReadLinesAt(lines [][]byte, offset int64) (n int, err error) +} + +type lineReaderAt struct { + r io.ReaderAt + index []int64 + eof bool +} + +func (r *lineReaderAt) ReadLinesAt(lines [][]byte, offset int64) (n int, err error) { + if offset < 0 { + return 0, errors.New("ReadLinesAt: negative offset") + } + if len(lines) == 0 { + return 0, nil + } + + count := len(lines) + startLine := offset + endLine := startLine + int64(count) + + if endLine > int64(len(r.index)) && !r.eof { + if err := r.indexTo(endLine); err != nil { + return 0, err + } + } + if startLine >= int64(len(r.index)) { + return 0, io.EOF + } + + buf, byteOffset, err := r.readBytes(startLine, int64(count)) + if err != nil { + return 0, err + } + + for n = 0; n < count && startLine+int64(n) < int64(len(r.index)); n++ { + lineno := startLine + int64(n) + start, end := int64(0), r.index[lineno]-byteOffset + if lineno > 0 { + start = r.index[lineno-1] - byteOffset + } + lines[n] = buf[start:end] + } + + if n < count { + return n, io.EOF + } + return n, nil +} + +// indexTo reads data and computes the line index until there is information +// for line or a read returns io.EOF. It returns an error if and only if there +// is an error reading data. +func (r *lineReaderAt) indexTo(line int64) error { + var buf [indexBufferSize]byte + + offset := r.lastOffset() + for int64(len(r.index)) < line { + n, err := r.r.ReadAt(buf[:], offset) + if err != nil && err != io.EOF { + return err + } + for _, b := range buf[:n] { + offset++ + if b == '\n' { + r.index = append(r.index, offset) + } + } + if err == io.EOF { + if offset > r.lastOffset() { + r.index = append(r.index, offset) + } + r.eof = true + break + } + } + return nil +} + +func (r *lineReaderAt) lastOffset() int64 { + if n := len(r.index); n > 0 { + return r.index[n-1] + } + return 0 +} + +// readBytes reads the bytes of the n lines starting at line and returns the +// bytes and the offset of the first byte in the underlying source. +func (r *lineReaderAt) readBytes(line, n int64) (b []byte, offset int64, err error) { + indexLen := int64(len(r.index)) + + var size int64 + if line > indexLen { + offset = r.index[indexLen-1] + } else if line > 0 { + offset = r.index[line-1] + } + if n > 0 { + if line+n > indexLen { + size = r.index[indexLen-1] - offset + } else { + size = r.index[line+n-1] - offset + } + } + + b = make([]byte, size) + if _, err := r.r.ReadAt(b, offset); err != nil { + if err == io.EOF { + err = errors.New("ReadLinesAt: corrupt line index or changed source data") + } + return nil, 0, err + } + return b, offset, nil +} + +func isLen(r io.ReaderAt, n int64) (bool, error) { + off := n - 1 + if off < 0 { + off = 0 + } + + var b [2]byte + nr, err := r.ReadAt(b[:], off) + if err == io.EOF { + return (n == 0 && nr == 0) || (n > 0 && nr == 1), nil + } + return false, err +} + +// copyFrom writes bytes starting from offset off in src to dst stopping at the +// end of src or at the first error. copyFrom returns the number of bytes +// written and any error. +func copyFrom(dst io.Writer, src io.ReaderAt, off int64) (written int64, err error) { + buf := make([]byte, byteBufferSize) + for { + nr, rerr := src.ReadAt(buf, off) + if nr > 0 { + nw, werr := dst.Write(buf[0:nr]) + if nw > 0 { + written += int64(nw) + } + if werr != nil { + err = werr + break + } + if nr != nw { + err = io.ErrShortWrite + break + } + off += int64(nr) + } + if rerr != nil { + if rerr != io.EOF { + err = rerr + } + break + } + } + return written, err +} + +// copyLinesFrom writes lines starting from line off in src to dst stopping at +// the end of src or at the first error. copyLinesFrom returns the number of +// lines written and any error. +func copyLinesFrom(dst io.Writer, src LineReaderAt, off int64) (written int64, err error) { + buf := make([][]byte, lineBufferSize) +ReadLoop: + for { + nr, rerr := src.ReadLinesAt(buf, off) + if nr > 0 { + for _, line := range buf[0:nr] { + nw, werr := dst.Write(line) + if nw > 0 { + written++ + } + if werr != nil { + err = werr + break ReadLoop + } + if len(line) != nw { + err = io.ErrShortWrite + break ReadLoop + } + } + off += int64(nr) + } + if rerr != nil { + if rerr != io.EOF { + err = rerr + } + break + } + } + return written, err +} diff --git a/internal/gitdiff/parser.go b/internal/gitdiff/parser.go new file mode 100644 index 0000000..e8f8430 --- /dev/null +++ b/internal/gitdiff/parser.go @@ -0,0 +1,142 @@ +// Package gitdiff parses and applies patches generated by Git. It supports +// line-oriented text patches, binary patches, and can also parse standard +// unified diffs generated by other tools. +package gitdiff + +import ( + "bufio" + "fmt" + "io" +) + +// Parse parses a patch with changes to one or more files. Any content before +// the first file is returned as the second value. If an error occurs while +// parsing, it returns all files parsed before the error. +// +// Parse expects to receive a single patch. If the input may contain multiple +// patches (for example, if it is an mbox file), callers should split it into +// individual patches and call Parse on each one. +func Parse(r io.Reader) ([]*File, string, error) { + p := newParser(r) + + if err := p.Next(); err != nil { + if err == io.EOF { + return nil, "", nil + } + return nil, "", err + } + + var preamble string + var files []*File + for { + file, pre, err := p.ParseNextFileHeader() + if err != nil { + return files, preamble, err + } + if len(files) == 0 { + preamble = pre + } + if file == nil { + break + } + + for _, fn := range []func(*File) (int, error){ + p.ParseTextFragments, + p.ParseBinaryFragments, + } { + n, err := fn(file) + if err != nil { + return files, preamble, err + } + if n > 0 { + break + } + } + + files = append(files, file) + } + + return files, preamble, nil +} + +// TODO(bkeyes): consider exporting the parser type with configuration +// this would enable OID validation, p-value guessing, and prefix stripping +// by allowing users to set or override defaults + +// parser invariants: +// - methods that parse objects: +// - start with the parser on the first line of the first object +// - if returning nil, do not advance +// - if returning an error, do not advance past the object +// - if returning an object, advance to the first line after the object +// - any exported parsing methods must initialize the parser by calling Next() + +type stringReader interface { + ReadString(delim byte) (string, error) +} + +type parser struct { + r stringReader + + eof bool + lineno int64 + lines [3]string +} + +func newParser(r io.Reader) *parser { + if r, ok := r.(stringReader); ok { + return &parser{r: r} + } + return &parser{r: bufio.NewReader(r)} +} + +// Next advances the parser by one line. It returns any error encountered while +// reading the line, including io.EOF when the end of stream is reached. +func (p *parser) Next() error { + if p.eof { + return io.EOF + } + + if p.lineno == 0 { + // on first call to next, need to shift in all lines + for i := 0; i < len(p.lines)-1; i++ { + if err := p.shiftLines(); err != nil && err != io.EOF { + return err + } + } + } + + err := p.shiftLines() + if err != nil && err != io.EOF { + return err + } + + p.lineno++ + if p.lines[0] == "" { + p.eof = true + return io.EOF + } + return nil +} + +func (p *parser) shiftLines() (err error) { + for i := 0; i < len(p.lines)-1; i++ { + p.lines[i] = p.lines[i+1] + } + p.lines[len(p.lines)-1], err = p.r.ReadString('\n') + return +} + +// Line returns a line from the parser without advancing it. A delta of 0 +// returns the current line, while higher deltas return read-ahead lines. It +// returns an empty string if the delta is higher than the available lines, +// either because of the buffer size or because the parser reached the end of +// the input. Valid lines always contain at least a newline character. +func (p *parser) Line(delta uint) string { + return p.lines[delta] +} + +// Errorf generates an error and appends the current line information. +func (p *parser) Errorf(delta int64, msg string, args ...interface{}) error { + return fmt.Errorf("gitdiff: line %d: %s", p.lineno+delta, fmt.Sprintf(msg, args...)) +} diff --git a/internal/gitdiff/patch_header.go b/internal/gitdiff/patch_header.go new file mode 100644 index 0000000..f047059 --- /dev/null +++ b/internal/gitdiff/patch_header.go @@ -0,0 +1,470 @@ +package gitdiff + +import ( + "bufio" + "errors" + "fmt" + "io" + "io/ioutil" + "mime/quotedprintable" + "net/mail" + "strconv" + "strings" + "time" + "unicode" +) + +const ( + mailHeaderPrefix = "From " + prettyHeaderPrefix = "commit " + mailMinimumHeaderPrefix = "From:" +) + +// PatchHeader is a parsed version of the preamble content that appears before +// the first diff in a patch. It includes metadata about the patch, such as the +// author and a subject. +type PatchHeader struct { + // The SHA of the commit the patch was generated from. Empty if the SHA is + // not included in the header. + SHA string + + // The author details of the patch. If these details are not included in + // the header, Author is nil and AuthorDate is the zero time. + Author *PatchIdentity + AuthorDate time.Time + + // The committer details of the patch. If these details are not included in + // the header, Committer is nil and CommitterDate is the zero time. + Committer *PatchIdentity + CommitterDate time.Time + + // The title and body of the commit message describing the changes in the + // patch. Empty if no message is included in the header. + Title string + Body string + + // If the preamble looks like an email, ParsePatchHeader will + // remove prefixes such as `Re: ` and `[PATCH v3 5/17]` from the + // Title and place them here. + SubjectPrefix string + + // If the preamble looks like an email, and it contains a `---` + // line, that line will be removed and everything after it will be + // placed in BodyAppendix. + BodyAppendix string +} + +// Message returns the commit message for the header. The message consists of +// the title and the body separated by an empty line. +func (h *PatchHeader) Message() string { + var msg strings.Builder + if h != nil { + msg.WriteString(h.Title) + if h.Body != "" { + msg.WriteString("\n\n") + msg.WriteString(h.Body) + } + } + return msg.String() +} + +// ParsePatchDate parses a patch date string. It returns the parsed time or an +// error if s has an unknown format. ParsePatchDate supports the iso, rfc, +// short, raw, unix, and default formats (with local variants) used by the +// --date flag in Git. +func ParsePatchDate(s string) (time.Time, error) { + const ( + isoFormat = "2006-01-02 15:04:05 -0700" + isoStrictFormat = "2006-01-02T15:04:05-07:00" + rfc2822Format = "Mon, 2 Jan 2006 15:04:05 -0700" + shortFormat = "2006-01-02" + defaultFormat = "Mon Jan 2 15:04:05 2006 -0700" + defaultLocalFormat = "Mon Jan 2 15:04:05 2006" + ) + + if s == "" { + return time.Time{}, nil + } + + for _, fmt := range []string{ + isoFormat, + isoStrictFormat, + rfc2822Format, + shortFormat, + defaultFormat, + defaultLocalFormat, + } { + if t, err := time.ParseInLocation(fmt, s, time.Local); err == nil { + return t, nil + } + } + + // unix format + if unix, err := strconv.ParseInt(s, 10, 64); err == nil { + return time.Unix(unix, 0), nil + } + + // raw format + if space := strings.IndexByte(s, ' '); space > 0 { + unix, uerr := strconv.ParseInt(s[:space], 10, 64) + zone, zerr := time.Parse("-0700", s[space+1:]) + if uerr == nil && zerr == nil { + return time.Unix(unix, 0).In(zone.Location()), nil + } + } + + return time.Time{}, fmt.Errorf("unknown date format: %s", s) +} + +// A PatchHeaderOption modifies the behavior of ParsePatchHeader. +type PatchHeaderOption func(*patchHeaderOptions) + +// SubjectCleanMode controls how ParsePatchHeader cleans subject lines when +// parsing mail-formatted patches. +type SubjectCleanMode int + +const ( + // SubjectCleanWhitespace removes leading and trailing whitespace. + SubjectCleanWhitespace SubjectCleanMode = iota + + // SubjectCleanAll removes leading and trailing whitespace, leading "Re:", + // "re:", and ":" strings, and leading strings enclosed by '[' and ']'. + // This is the default behavior of git (see `git mailinfo`) and this + // package. + SubjectCleanAll + + // SubjectCleanPatchOnly is the same as SubjectCleanAll, but only removes + // leading strings enclosed by '[' and ']' if they start with "PATCH". + SubjectCleanPatchOnly +) + +// WithSubjectCleanMode sets the SubjectCleanMode for header parsing. By +// default, uses SubjectCleanAll. +func WithSubjectCleanMode(m SubjectCleanMode) PatchHeaderOption { + return func(opts *patchHeaderOptions) { + opts.subjectCleanMode = m + } +} + +type patchHeaderOptions struct { + subjectCleanMode SubjectCleanMode +} + +// ParsePatchHeader parses the preamble string returned by [Parse] into a +// PatchHeader. Due to the variety of header formats, some fields of the parsed +// PatchHeader may be unset after parsing. +// +// Supported formats are the short, medium, full, fuller, and email pretty +// formats used by `git diff`, `git log`, and `git show` and the UNIX mailbox +// format used by `git format-patch`. +// +// When parsing mail-formatted headers, ParsePatchHeader tries to remove +// email-specific content from the title and body: +// +// - Based on the SubjectCleanMode, remove prefixes like reply markers and +// "[PATCH]" strings from the subject, saving any removed content in the +// SubjectPrefix field. Parsing always discards leading and trailing +// whitespace from the subject line. The default mode is SubjectCleanAll. +// +// - If the body contains a "---" line (3 hyphens), remove that line and any +// content after it from the body and save it in the BodyAppendix field. +// +// ParsePatchHeader tries to process content it does not understand wthout +// returning errors, but will return errors if well-identified content like +// dates or identies uses unknown or invalid formats. +func ParsePatchHeader(header string, options ...PatchHeaderOption) (*PatchHeader, error) { + opts := patchHeaderOptions{ + subjectCleanMode: SubjectCleanAll, // match git defaults + } + for _, optFn := range options { + optFn(&opts) + } + + header = strings.TrimSpace(header) + if header == "" { + return &PatchHeader{}, nil + } + + var firstLine, rest string + if idx := strings.IndexByte(header, '\n'); idx >= 0 { + firstLine = header[:idx] + rest = header[idx+1:] + } else { + firstLine = header + rest = "" + } + + switch { + case strings.HasPrefix(firstLine, mailHeaderPrefix): + return parseHeaderMail(firstLine, strings.NewReader(rest), opts) + + case strings.HasPrefix(firstLine, mailMinimumHeaderPrefix): + // With a minimum header, the first line is part of the actual mail + // content and needs to be parsed as part of the "rest" + return parseHeaderMail("", strings.NewReader(header), opts) + + case strings.HasPrefix(firstLine, prettyHeaderPrefix): + return parseHeaderPretty(firstLine, strings.NewReader(rest)) + } + + return nil, errors.New("unrecognized patch header format") +} + +func parseHeaderPretty(prettyLine string, r io.Reader) (*PatchHeader, error) { + const ( + authorPrefix = "Author:" + commitPrefix = "Commit:" + datePrefix = "Date:" + authorDatePrefix = "AuthorDate:" + commitDatePrefix = "CommitDate:" + ) + + h := &PatchHeader{} + + prettyLine = strings.TrimPrefix(prettyLine, prettyHeaderPrefix) + if i := strings.IndexByte(prettyLine, ' '); i > 0 { + h.SHA = prettyLine[:i] + } else { + h.SHA = prettyLine + } + + s := bufio.NewScanner(r) + for s.Scan() { + line := s.Text() + + // empty line marks end of fields, remaining lines are title/message + if strings.TrimSpace(line) == "" { + break + } + + switch { + case strings.HasPrefix(line, authorPrefix): + u, err := ParsePatchIdentity(line[len(authorPrefix):]) + if err != nil { + return nil, err + } + h.Author = &u + + case strings.HasPrefix(line, commitPrefix): + u, err := ParsePatchIdentity(line[len(commitPrefix):]) + if err != nil { + return nil, err + } + h.Committer = &u + + case strings.HasPrefix(line, datePrefix): + d, err := ParsePatchDate(strings.TrimSpace(line[len(datePrefix):])) + if err != nil { + return nil, err + } + h.AuthorDate = d + + case strings.HasPrefix(line, authorDatePrefix): + d, err := ParsePatchDate(strings.TrimSpace(line[len(authorDatePrefix):])) + if err != nil { + return nil, err + } + h.AuthorDate = d + + case strings.HasPrefix(line, commitDatePrefix): + d, err := ParsePatchDate(strings.TrimSpace(line[len(commitDatePrefix):])) + if err != nil { + return nil, err + } + h.CommitterDate = d + } + } + if s.Err() != nil { + return nil, s.Err() + } + + title, indent := scanMessageTitle(s) + if s.Err() != nil { + return nil, s.Err() + } + h.Title = title + + if title != "" { + // Don't check for an appendix, pretty headers do not contain them + body, _ := scanMessageBody(s, indent, false) + if s.Err() != nil { + return nil, s.Err() + } + h.Body = body + } + + return h, nil +} + +func scanMessageTitle(s *bufio.Scanner) (title string, indent string) { + var b strings.Builder + for i := 0; s.Scan(); i++ { + line := s.Text() + trimLine := strings.TrimSpace(line) + if trimLine == "" { + break + } + + if i == 0 { + if start := strings.IndexFunc(line, func(c rune) bool { return !unicode.IsSpace(c) }); start > 0 { + indent = line[:start] + } + } + if b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteString(trimLine) + } + return b.String(), indent +} + +func scanMessageBody(s *bufio.Scanner, indent string, separateAppendix bool) (string, string) { + // Body and appendix + var body, appendix strings.Builder + c := &body + var empty int + for i := 0; s.Scan(); i++ { + line := s.Text() + + line = strings.TrimRightFunc(line, unicode.IsSpace) + line = strings.TrimPrefix(line, indent) + + if line == "" { + empty++ + continue + } + + // If requested, parse out "appendix" information (often added + // by `git format-patch` and removed by `git am`). + if separateAppendix && c == &body && line == "---" { + c = &appendix + continue + } + + if c.Len() > 0 { + c.WriteByte('\n') + if empty > 0 { + c.WriteByte('\n') + } + } + empty = 0 + + c.WriteString(line) + } + return body.String(), appendix.String() +} + +func parseHeaderMail(mailLine string, r io.Reader, opts patchHeaderOptions) (*PatchHeader, error) { + msg, err := mail.ReadMessage(r) + if err != nil { + return nil, err + } + + h := &PatchHeader{} + + if strings.HasPrefix(mailLine, mailHeaderPrefix) { + mailLine = strings.TrimPrefix(mailLine, mailHeaderPrefix) + if i := strings.IndexByte(mailLine, ' '); i > 0 { + h.SHA = mailLine[:i] + } + } + + from := msg.Header.Get("From") + if from != "" { + u, err := ParsePatchIdentity(from) + if err != nil { + return nil, err + } + h.Author = &u + } + + date := msg.Header.Get("Date") + if date != "" { + d, err := ParsePatchDate(date) + if err != nil { + return nil, err + } + h.AuthorDate = d + } + + subject := msg.Header.Get("Subject") + h.SubjectPrefix, h.Title = cleanSubject(subject, opts.subjectCleanMode) + + s := bufio.NewScanner(msg.Body) + h.Body, h.BodyAppendix = scanMessageBody(s, "", true) + if s.Err() != nil { + return nil, s.Err() + } + + return h, nil +} + +func cleanSubject(s string, mode SubjectCleanMode) (prefix string, subject string) { + switch mode { + case SubjectCleanAll, SubjectCleanPatchOnly: + case SubjectCleanWhitespace: + return "", strings.TrimSpace(decodeSubject(s)) + default: + panic(fmt.Sprintf("unknown clean mode: %d", mode)) + } + + // Based on the algorithm from Git in mailinfo.c:cleanup_subject() + // If compatibility with `git am` drifts, go there to see if there are any updates. + + at := 0 + for at < len(s) { + switch s[at] { + case 'r', 'R': + // Detect re:, Re:, rE: and RE: + if at+2 < len(s) && (s[at+1] == 'e' || s[at+1] == 'E') && s[at+2] == ':' { + at += 3 + continue + } + + case ' ', '\t', ':': + // Delete whitespace and duplicate ':' characters + at++ + continue + + case '[': + if i := strings.IndexByte(s[at:], ']'); i > 0 { + if mode == SubjectCleanAll || strings.Contains(s[at:at+i+1], "PATCH") { + at += i + 1 + continue + } + } + } + + // Nothing was removed, end processing + break + } + + prefix = strings.TrimLeftFunc(s[:at], unicode.IsSpace) + subject = strings.TrimRightFunc(decodeSubject(s[at:]), unicode.IsSpace) + return +} + +// Decodes a subject line. Currently only supports quoted-printable UTF-8. This format is the result +// of a `git format-patch` when the commit title has a non-ASCII character (i.e. an emoji). +// See for reference: https://stackoverflow.com/questions/27695749/gmail-api-not-respecting-utf-encoding-in-subject +func decodeSubject(encoded string) string { + if !strings.HasPrefix(encoded, "=?UTF-8?q?") { + // not UTF-8 encoded + return encoded + } + + // If the subject is too long, `git format-patch` may produce a subject line across + // multiple lines. When parsed, this can look like the following: + // <UTF8-prefix><first-line> <UTF8-prefix><second-line> + payload := " " + encoded + payload = strings.ReplaceAll(payload, " =?UTF-8?q?", "") + payload = strings.ReplaceAll(payload, "?=", "") + + decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(payload))) + if err != nil { + // if err, abort decoding and return original subject + return encoded + } + + return string(decoded) +} diff --git a/internal/gitdiff/patch_identity.go b/internal/gitdiff/patch_identity.go new file mode 100644 index 0000000..018f80c --- /dev/null +++ b/internal/gitdiff/patch_identity.go @@ -0,0 +1,166 @@ +package gitdiff + +import ( + "fmt" + "strings" +) + +// PatchIdentity identifies a person who authored or committed a patch. +type PatchIdentity struct { + Name string + Email string +} + +func (i PatchIdentity) String() string { + name := i.Name + if name == "" { + name = `""` + } + return fmt.Sprintf("%s <%s>", name, i.Email) +} + +// ParsePatchIdentity parses a patch identity string. A patch identity contains +// an email address and an optional name in [RFC 5322] format. This is either a +// plain email adddress or a name followed by an address in angle brackets: +// +// author@example.com +// Author Name <author@example.com> +// +// If the input is not one of these formats, ParsePatchIdentity applies a +// heuristic to separate the name and email portions. If both the name and +// email are missing or empty, ParsePatchIdentity returns an error. It +// otherwise does not validate the result. +// +// [RFC 5322]: https://datatracker.ietf.org/doc/html/rfc5322 +func ParsePatchIdentity(s string) (PatchIdentity, error) { + s = normalizeSpace(s) + s = unquotePairs(s) + + var name, email string + if at := strings.IndexByte(s, '@'); at >= 0 { + start, end := at, at + for start >= 0 && !isRFC5332Space(s[start]) && s[start] != '<' { + start-- + } + for end < len(s) && !isRFC5332Space(s[end]) && s[end] != '>' { + end++ + } + email = s[start+1 : end] + + // Adjust the boundaries so that we drop angle brackets, but keep + // spaces when removing the email to form the name. + if start < 0 || s[start] != '<' { + start++ + } + if end >= len(s) || s[end] != '>' { + end-- + } + name = s[:start] + s[end+1:] + } else { + start, end := 0, 0 + for i := 0; i < len(s); i++ { + if s[i] == '<' && start == 0 { + start = i + 1 + } + if s[i] == '>' && start > 0 { + end = i + break + } + } + if start > 0 && end >= start { + email = strings.TrimSpace(s[start:end]) + name = s[:start-1] + } + } + + // After extracting the email, the name might contain extra whitespace + // again and may be surrounded by comment characters. The git source gives + // these examples of when this can happen: + // + // "Name <email@domain>" + // "email@domain (Name)" + // "Name <email@domain> (Comment)" + // + name = normalizeSpace(name) + if strings.HasPrefix(name, "(") && strings.HasSuffix(name, ")") { + name = name[1 : len(name)-1] + } + name = strings.TrimSpace(name) + + // If the name is empty or contains email-like characters, use the email + // instead (assuming one exists) + if name == "" || strings.ContainsAny(name, "@<>") { + name = email + } + + if name == "" && email == "" { + return PatchIdentity{}, fmt.Errorf("invalid identity string %q", s) + } + return PatchIdentity{Name: name, Email: email}, nil +} + +// unquotePairs process the RFC5322 tokens "quoted-string" and "comment" to +// remove any "quoted-pairs" (backslash-espaced characters). It also removes +// the quotes from any quoted strings, but leaves the comment delimiters. +func unquotePairs(s string) string { + quote := false + comments := 0 + escaped := false + + var out strings.Builder + for i := 0; i < len(s); i++ { + if escaped { + escaped = false + } else { + switch s[i] { + case '\\': + // quoted-pair is only allowed in quoted-string/comment + if quote || comments > 0 { + escaped = true + continue // drop '\' character + } + + case '"': + if comments == 0 { + quote = !quote + continue // drop '"' character + } + + case '(': + if !quote { + comments++ + } + case ')': + if comments > 0 { + comments-- + } + } + } + out.WriteByte(s[i]) + } + return out.String() +} + +// normalizeSpace trims leading and trailing whitespace from s and converts +// inner sequences of one or more whitespace characters to single spaces. +func normalizeSpace(s string) string { + var sb strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if !isRFC5332Space(c) { + if sb.Len() > 0 && isRFC5332Space(s[i-1]) { + sb.WriteByte(' ') + } + sb.WriteByte(c) + } + } + return sb.String() +} + +func isRFC5332Space(c byte) bool { + switch c { + case '\t', '\n', '\r', ' ': + return true + } + return false +} diff --git a/internal/gitdiff/text.go b/internal/gitdiff/text.go new file mode 100644 index 0000000..ee30792 --- /dev/null +++ b/internal/gitdiff/text.go @@ -0,0 +1,192 @@ +package gitdiff + +import ( + "fmt" + "io" + "strconv" + "strings" +) + +// ParseTextFragments parses text fragments until the next file header or the +// end of the stream and attaches them to the given file. It returns the number +// of fragments that were added. +func (p *parser) ParseTextFragments(f *File) (n int, err error) { + for { + frag, err := p.ParseTextFragmentHeader() + if err != nil { + return n, err + } + if frag == nil { + return n, nil + } + + if f.IsNew && frag.OldLines > 0 { + return n, p.Errorf(-1, "new file depends on old contents") + } + if f.IsDelete && frag.NewLines > 0 { + return n, p.Errorf(-1, "deleted file still has contents") + } + + if err := p.ParseTextChunk(frag); err != nil { + return n, err + } + + f.TextFragments = append(f.TextFragments, frag) + n++ + } +} + +func (p *parser) ParseTextFragmentHeader() (*TextFragment, error) { + const ( + startMark = "@@ -" + endMark = " @@" + ) + + if !strings.HasPrefix(p.Line(0), startMark) { + return nil, nil + } + + parts := strings.SplitAfterN(p.Line(0), endMark, 2) + if len(parts) < 2 { + return nil, p.Errorf(0, "invalid fragment header") + } + + f := &TextFragment{} + f.Comment = strings.TrimSpace(parts[1]) + + header := parts[0][len(startMark) : len(parts[0])-len(endMark)] + ranges := strings.Split(header, " +") + if len(ranges) != 2 { + return nil, p.Errorf(0, "invalid fragment header") + } + + var err error + if f.OldPosition, f.OldLines, err = parseRange(ranges[0]); err != nil { + return nil, p.Errorf(0, "invalid fragment header: %v", err) + } + if f.NewPosition, f.NewLines, err = parseRange(ranges[1]); err != nil { + return nil, p.Errorf(0, "invalid fragment header: %v", err) + } + + if err := p.Next(); err != nil && err != io.EOF { + return nil, err + } + return f, nil +} + +func (p *parser) ParseTextChunk(frag *TextFragment) error { + if p.Line(0) == "" { + return p.Errorf(0, "no content following fragment header") + } + + oldLines, newLines := frag.OldLines, frag.NewLines + for oldLines > 0 || newLines > 0 { + line := p.Line(0) + op, data := line[0], line[1:] + + switch op { + case '\n': + data = "\n" + fallthrough // newer GNU diff versions create empty context lines + case ' ': + oldLines-- + newLines-- + if frag.LinesAdded == 0 && frag.LinesDeleted == 0 { + frag.LeadingContext++ + } else { + frag.TrailingContext++ + } + frag.Lines = append(frag.Lines, Line{OpContext, data}) + case '-': + oldLines-- + frag.LinesDeleted++ + frag.TrailingContext = 0 + frag.Lines = append(frag.Lines, Line{OpDelete, data}) + case '+': + newLines-- + frag.LinesAdded++ + frag.TrailingContext = 0 + frag.Lines = append(frag.Lines, Line{OpAdd, data}) + case '\\': + // this may appear in middle of fragment if it's for a deleted line + if isNoNewlineMarker(line) { + removeLastNewline(frag) + break + } + fallthrough + default: + // TODO(bkeyes): if this is because we hit the next header, it + // would be helpful to return the miscounts line error. We could + // either test for the common headers ("@@ -", "diff --git") or + // assume any invalid op ends the fragment; git returns the same + // generic error in all cases so either is compatible + return p.Errorf(0, "invalid line operation: %q", op) + } + + if err := p.Next(); err != nil { + if err == io.EOF { + break + } + return err + } + } + + if oldLines != 0 || newLines != 0 { + hdr := max(frag.OldLines-oldLines, frag.NewLines-newLines) + 1 + return p.Errorf(-hdr, "fragment header miscounts lines: %+d old, %+d new", -oldLines, -newLines) + } + if frag.LinesAdded == 0 && frag.LinesDeleted == 0 { + return p.Errorf(0, "fragment contains no changes") + } + + // check for a final "no newline" marker since it is not included in the + // counters used to stop the loop above + if isNoNewlineMarker(p.Line(0)) { + removeLastNewline(frag) + if err := p.Next(); err != nil && err != io.EOF { + return err + } + } + + return nil +} + +func isNoNewlineMarker(s string) bool { + // test for "\ No newline at end of file" by prefix because the text + // changes by locale (git claims all versions are at least 12 chars) + return len(s) >= 12 && s[:2] == "\\ " +} + +func removeLastNewline(frag *TextFragment) { + if len(frag.Lines) > 0 { + last := &frag.Lines[len(frag.Lines)-1] + last.Line = strings.TrimSuffix(last.Line, "\n") + } +} + +func parseRange(s string) (start int64, end int64, err error) { + parts := strings.SplitN(s, ",", 2) + + if start, err = strconv.ParseInt(parts[0], 10, 64); err != nil { + nerr := err.(*strconv.NumError) + return 0, 0, fmt.Errorf("bad start of range: %s: %v", parts[0], nerr.Err) + } + + if len(parts) > 1 { + if end, err = strconv.ParseInt(parts[1], 10, 64); err != nil { + nerr := err.(*strconv.NumError) + return 0, 0, fmt.Errorf("bad end of range: %s: %v", parts[1], nerr.Err) + } + } else { + end = 1 + } + + return +} + +func max(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/links/links.go b/internal/links/links.go new file mode 100644 index 0000000..6935be0 --- /dev/null +++ b/internal/links/links.go @@ -0,0 +1,179 @@ +package links + +import ( + "bytes" + "net/url" + "path" + "strings" + + "golang.org/x/net/html" + + "mokhan.ca/antonmedv/gitmal/internal/git" +) + +type Set map[string]struct{} + +func BuildDirSet(files []git.Blob) Set { + dirs := make(Set) + for _, f := range files { + dir := path.Dir(f.Path) + for dir != "." && dir != "/" { + if _, ok := dirs[dir]; ok { + break + } + dirs[dir] = struct{}{} + if i := strings.LastIndex(dir, "/"); i != -1 { + dir = dir[:i] + } else { + break + } + } + } + return dirs +} + +func BuildFileSet(files []git.Blob) Set { + filesSet := make(Set) + for _, f := range files { + filesSet[f.Path] = struct{}{} + } + return filesSet +} + +func Resolve(content, currentPath, rootHref, ref string, dirs, files Set) string { + doc, err := html.Parse(strings.NewReader(content)) + if err != nil { + return content + } + + baseDir := path.Dir(currentPath) + + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode { + switch n.Data { + case "a": + for i, attr := range n.Attr { + if attr.Key == "href" { + newHref := transformHref(attr.Val, baseDir, rootHref, ref, files, dirs) + n.Attr[i].Val = newHref + break + } + } + case "img": + for i, attr := range n.Attr { + if attr.Key == "src" { + newSrc := transformImgSrc(attr.Val, baseDir, rootHref, ref) + n.Attr[i].Val = newSrc + break + } + } + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(doc) + + var buf bytes.Buffer + if err := html.Render(&buf, doc); err != nil { + return content + } + + return buf.String() +} + +func transformHref(href, baseDir, rootHref, ref string, files, dirs Set) string { + if href == "" { + return href + } + if strings.HasPrefix(href, "#") { + return href + } + + u, err := url.Parse(href) + if err != nil { + return href + } + + // Absolute URLs are left untouched + if u.IsAbs() { + return href + } + + // Skip mailto:, javascript:, data: etc. (url.Parse sets Scheme) + if u.Scheme != "" { + return href + } + + // Resolve against the directory of the current file + relPath := u.Path + if relPath == "" { + return href + } + + var repoPath string + + if strings.HasPrefix(relPath, "/") { + // Root-relative repo path + relPath = strings.TrimPrefix(relPath, "/") + repoPath = path.Clean(relPath) + } else { + // Relative to current file directory + repoPath = path.Clean(path.Join(baseDir, relPath)) + } + + // Decide if this is a file or a directory in the repo + var newPath string + + // 1) Exact file match + if _, ok := files[repoPath]; ok { + newPath = repoPath + ".html" + } else if _, ok := files[repoPath+".md"]; ok { + // 2) Maybe the link omitted ".md" but the repo has it + newPath = repoPath + ".md.html" + } else if _, ok := dirs[repoPath]; ok { + // 3) Directory: add /index.html + newPath = path.Join(repoPath, "index.html") + } else { + // Unknown target, leave as-is + return href + } + + // Link from the root href + newPath = path.Join(rootHref, "blob", ref, newPath) + + // Preserve any query/fragment if they existed + u.Path = newPath + return u.String() +} + +func transformImgSrc(src, baseDir, rootHref, ref string) string { + u, err := url.Parse(src) + if err != nil { + return src + } + + if u.IsAbs() { + return src + } + + relPath := u.Path + + var repoPath string + if strings.HasPrefix(relPath, "/") { + // Root-relative: drop leading slash + repoPath = strings.TrimPrefix(relPath, "/") + } else { + // Resolve against current file directory + repoPath = path.Clean(path.Join(baseDir, relPath)) + } + + final := path.Join(rootHref, "raw", ref, repoPath) + + // Preserve any query/fragment if they existed + u.Path = final + return u.String() +} diff --git a/internal/progress_bar/progress_bar.go b/internal/progress_bar/progress_bar.go new file mode 100644 index 0000000..8f39ec5 --- /dev/null +++ b/internal/progress_bar/progress_bar.go @@ -0,0 +1,84 @@ +package progress_bar + +import ( + "fmt" + "os" + "strings" + "sync" + "sync/atomic" + "time" +) + +type ProgressBar struct { + label string + total int64 + current int64 + stop chan struct{} + wg sync.WaitGroup +} + +func NewProgressBar(label string, total int) *ProgressBar { + p := &ProgressBar{label: label, total: int64(total), stop: make(chan struct{})} + p.wg.Add(1) + go func() { + defer p.wg.Done() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + // initial draw + p.draw(atomic.LoadInt64(&p.current)) + for { + select { + case <-p.stop: + return + case <-ticker.C: + cur := atomic.LoadInt64(&p.current) + if cur > p.total { + cur = p.total + } + p.draw(cur) + } + } + }() + return p +} + +func (p *ProgressBar) Inc() { + for { + cur := atomic.LoadInt64(&p.current) + if cur >= p.total { + return + } + if atomic.CompareAndSwapInt64(&p.current, cur, cur+1) { + return + } + // retry on race + } +} + +func (p *ProgressBar) Done() { + atomic.StoreInt64(&p.current, p.total) + close(p.stop) + p.wg.Wait() + p.draw(p.total) + _, _ = fmt.Fprintln(os.Stderr) +} + +func (p *ProgressBar) draw(current int64) { + if p.total <= 0 { + return + } + percent := 0 + if p.total > 0 { + percent = int(current * 100 / p.total) + } + barLen := 24 + filled := 0 + if p.total > 0 { + filled = int(current * int64(barLen) / p.total) + } + if filled > barLen { + filled = barLen + } + bar := strings.Repeat("#", filled) + strings.Repeat(" ", barLen-filled) + _, _ = fmt.Fprintf(os.Stderr, "\r[%s] %4d/%-4d (%3d%%) %s", bar, current, p.total, percent, p.label) +} diff --git a/internal/templates/blob.gohtml b/internal/templates/blob.gohtml new file mode 100644 index 0000000..b98e2b3 --- /dev/null +++ b/internal/templates/blob.gohtml @@ -0,0 +1,72 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.BlobParams */ -}} +{{ define "head" }} + <style> + {{ .CSS }} + + [id] { + scroll-margin-top: var(--header-height); + } + + pre { + border: 1px solid var(--c-border); + border-top: none; + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + } + + pre { + margin: 0; + padding: 8px 16px; + overflow-x: auto; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + tab-size: 4; + font-family: var(--font-family-mono), monospace; + } + + pre > code { + display: block; + padding: 0 16px; + width: fit-content; + min-width: 100%; + line-height: var(--code-line-height); + font-size: var(--code-font-size); + } + + .border { + border: 1px solid var(--c-border); + border-top: none; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } + + .binary-file { + padding: 8px 16px; + font-style: italic; + } + + .image { + padding: 8px 16px; + text-align: center; + } + + .image img { + max-width: 100%; + height: auto; + } + </style> +{{ end }} + +{{ define "body" }} + {{ template "header" . }} + {{ if .IsImage }} + <div class="image border">{{.Content}}</div> + {{ else if .IsBinary}} + <div class="binary-file border">Binary file</div> + {{ else }} + {{ .Content }} + {{ end }} +{{ end }} + diff --git a/internal/templates/branches.gohtml b/internal/templates/branches.gohtml new file mode 100644 index 0000000..743461d --- /dev/null +++ b/internal/templates/branches.gohtml @@ -0,0 +1,58 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.BranchesParams*/ -}} +{{ define "head" }} + <style> + .branches { + border: 1px solid var(--c-border); + border-radius: var(--border-radius); + overflow: hidden; + } + + .branch-row { + display: flex; + align-items: center; + gap: 12px; + height: 44px; + padding-inline: 16px; + border-bottom: 1px solid var(--c-border); + background-color: var(--c-bg-elv); + } + + .branch-row:last-child { + border-bottom: none; + } + + .branch-row a { + color: var(--c-text-1); + } + + .branch-row a:hover { + color: var(--c-brand-2); + text-decoration: none; + } + + .badge { + font-size: 12px; + color: var(--c-text-2); + border: 1px solid var(--c-border); + padding: 2px 6px; + border-radius: 999px; + } + </style> +{{ end }} + +{{ define "body" }} + <h1>Branches</h1> + <div class="branches"> + {{ if .Branches }} + {{ range .Branches }} + <div class="branch-row"> + <a href="{{ .Href }}">{{ .Name }}</a> + {{ if .IsDefault }}<span class="badge">default</span>{{ end }} + <a href="{{ .CommitsHref }}" style="margin-left:auto; font-size:13px; color: var(--c-text-2)">commits โ</a> + </div> + {{ end }} + {{ else }} + <div class="branch-row">(no branches)</div> + {{ end }} + </div> +{{ end }} diff --git a/internal/templates/commit.gohtml b/internal/templates/commit.gohtml new file mode 100644 index 0000000..3176f97 --- /dev/null +++ b/internal/templates/commit.gohtml @@ -0,0 +1,314 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.CommitParams*/ -}} +{{ define "head" }} + <style> + h1 code { + border-radius: var(--border-radius); + background: var(--c-bg-alt); + padding: 4px 8px; + font-family: var(--font-family-mono), monospace; + font-weight: 500; + } + + .commit { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; + } + + .commit-info { + display: flex; + flex-direction: row; + gap: 8px; + align-items: flex-end; + } + + .commit-author { + color: var(--c-text-1); + font-weight: 600; + min-width: 0; + overflow-wrap: break-word; + } + + .commit-date { + color: var(--c-text-2); + font-size: 12px; + font-family: var(--font-family-mono), monospace; + } + + .commit-message { + border: 1px solid var(--c-border); + border-radius: var(--border-radius); + padding: 16px; + } + + .ref-badges { + display: inline-flex; + gap: 6px; + margin-left: 8px; + } + + .badge { + font-size: 12px; + color: var(--c-text-2); + border: 1px solid var(--c-border); + padding: 2px 6px; + border-radius: 999px; + white-space: nowrap; + } + + .commit-subject { + font-size: 16px; + font-weight: 600; + line-height: 1.3; + hyphens: auto; + } + + .commit-body { + margin-top: 16px; + white-space: pre-wrap; + line-height: 1.5; + hyphens: auto; + } + + .commit-subinfo { + display: flex; + flex-direction: row; + gap: 16px; + justify-content: space-between; + } + + .commit-layout { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + } + + @media (min-width: 960px) { + .commit-layout { + grid-template-columns: 300px 1fr; + align-items: start; + } + + .files-tree { + position: sticky; + top: 16px; + } + + .files-tree-content { + max-height: calc(100vh - var(--header-height) - 40px); + overflow: auto; + } + } + + .files-tree { + border: 1px solid var(--c-border); + border-radius: var(--border-radius); + } + + .files-tree-header { + display: flex; + flex-direction: row; + align-items: center; + padding-inline: 16px; + height: var(--header-height); + border-bottom: 1px solid var(--c-border); + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + background: var(--c-bg-alt); + font-size: 14px; + font-weight: 600; + } + + .files-tree-content { + display: block; + padding-block: 6px; + } + + .tree .children { + margin-left: 16px; + border-left: 1px dashed var(--c-border); + } + + .tree .node { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + } + + .tree .file-name { + flex: 1; + font-weight: 500; + color: var(--c-text-1); + cursor: pointer; + } + + .tree .file-name:hover { + color: var(--c-brand-2); + text-decoration: underline; + } + + .tree .dir { + color: var(--c-dir); + } + + .tree .file-added { + color: var(--c-green); + } + + .tree .file-deleted { + color: var(--c-red); + } + + .tree .file-renamed { + color: var(--c-yellow); + } + + /* Per-file sections */ + + .files { + min-width: 0; + } + + .file-section + .file-section { + margin-top: 16px; + } + + pre { + border: 1px solid var(--c-border); + border-top: none; + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + } + + pre { + margin: 0; + padding: 8px 16px; + overflow-x: auto; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + tab-size: 4; + font-family: var(--font-family-mono), monospace; + } + + pre > code { + display: block; + padding: 0 16px; + width: fit-content; + min-width: 100%; + line-height: var(--code-line-height); + font-size: var(--code-font-size); + } + + .binary-file { + padding: 8px 16px; + font-style: italic; + } + + .border { + border: 1px solid var(--c-border); + border-top: none; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } + + {{ .DiffCSS }} + </style> +{{ end }} + +{{ define "body" }} + <h1>Commit <code>{{ .Commit.ShortHash }}</code></h1> + + <div class="commit"> + <div class="commit-info"> + <div class="commit-author">{{ .Commit.Author }} <{{ .Commit.Email }}></div> + <div class="commit-date">{{ .Commit.Date | FormatDate }}</div> + </div> + <div class="commit-message"> + <div class="commit-subject"> + {{ .Commit.Subject }} + {{ if .Commit.RefNames }} + <span class="ref-badges"> + {{ range .Commit.RefNames }} + {{ if or (eq .Kind "Branch") (eq .Kind "Tag") }} + <span class="badge">{{ if eq .Kind "Tag" }}tag: {{ end }}{{ .Name }}</span> + {{ end }} + {{ end }} + </span> + {{ end }} + </div> + {{ if .Commit.Body }} + <div class="commit-body">{{ .Commit.Body }}</div> + {{ end }} + </div> + <div class="commit-subinfo"> + <div class="commit-branch"> + {{ if .Commit.Branch }} + <a href="../commits/{{ .Commit.Branch.DirName }}/index.html" class="badge">{{ .Commit.Branch }}</a> + {{ end }} + </div> + {{ if .Commit.Parents }} + <div class="commit-parents"> + {{ if eq (len .Commit.Parents) 1 }} + 1 parent + {{ else }} + {{ .Commit.Parents | len }} parents + {{ end }} + {{ range $i, $p := .Commit.Parents }} + {{ if gt $i 0 }}, {{ end }} + <a href="{{ $p }}.html"><code>{{ $p | ShortHash }}</code></a> + {{ end }} + </div> + {{ end }} + </div> + </div> + + <div class="commit-layout"> + <div class="files-tree"> + <div class="files-tree-header">Changed files ({{ len .FileViews }})</div> + <div class="files-tree-content"> + {{ if .FileTree }} + <div class="tree"> + {{ template "file_tree" (FileTreeParams .FileTree) }} + </div> + {{ else }} + <div style="color: var(--c-text-3)">(no files)</div> + {{ end }} + </div> + </div> + + <div class="files"> + {{ range .FileViews }} + <section id="{{ .Path }}" class="file-section"> + <div class="header-container"> + <header class="file-header"> + {{ if .IsRename }} + <div class="path">{{ .OldName }} โ {{ .NewName }}</div> + {{ else }} + <div class="path">{{ .Path }}</div> + {{ end }} + <button class="goto-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})"> + <svg aria-hidden="true" focusable="false" width="16" height="16"> + <use xlink:href="#arrow-top"></use> + </svg> + Top + </button> + </header> + </div> + {{ if .IsBinary }} + <div class="binary-file border">Binary file</div> + {{ else if (and .IsRename (not .HasChanges)) }} + <div class="binary-file border">File renamed without changes</div> + {{ else }} + <div class="file-diff"> + {{ .HTML }} + </div> + {{ end }} + </section> + {{ end }} + </div> + </div> +{{ end }} diff --git a/internal/templates/commits_list.gohtml b/internal/templates/commits_list.gohtml new file mode 100644 index 0000000..76dd43c --- /dev/null +++ b/internal/templates/commits_list.gohtml @@ -0,0 +1,203 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.CommitsListParams*/ -}} +{{ define "head" }} + <style> + .commits { + border: 1px solid var(--c-border); + border-top: none; + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + overflow-x: auto; + } + + .row { + display: flex; + height: 40px; + border-bottom: 1px solid var(--c-border); + padding-inline: 16px; + gap: 16px; + } + + .row:last-child { + border-bottom: none; + } + + .row:hover { + background-color: var(--c-bg-alt); + } + + .cell { + display: flex; + gap: 8px; + align-items: center; + } + + .hash a { + font-family: var(--font-family-mono), monospace; + color: var(--c-text-2); + } + + .commit-title { + flex: 1; + } + + .commit-title a { + color: var(--c-text-1); + line-height: 1.5; + } + + .commit-title a:hover { + color: var(--c-brand-2); + } + + .ref-badges { + display: inline-flex; + gap: 6px; + margin-left: 8px; + } + + .badge { + font-size: 12px; + color: var(--c-text-2); + border: 1px solid var(--c-border); + padding: 2px 6px; + border-radius: 999px; + white-space: nowrap; + } + + .date { + font-family: var(--font-family-mono), monospace; + font-size: 12px; + color: var(--c-text-2); + } + + .pagination { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + margin-top: 12px; + padding: 8px 0; + color: var(--c-text-2); + } + + .pagination a, + .pagination span.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--c-border); + border-radius: 6px; + background: var(--c-bg-elv); + color: var(--c-text-1); + text-decoration: none; + font-size: 13px; + } + + .pagination a:hover { + color: var(--c-brand-2); + border-color: var(--c-brand-2); + } + + .pagination .disabled { + opacity: 0.5; + pointer-events: none; + } + + .pagination .page-indicator { + margin: 0 4px; + font-size: 13px; + color: var(--c-text-2); + } + + /* Mobile optimizations */ + @media (max-width: 767px) { + .row { + height: auto; + padding: 10px 12px; + gap: 12px; + flex-wrap: wrap; + align-items: flex-start; + } + + .commit-title { + flex-basis: 100%; + order: 1; + } + + .author, .date, .hash { + font-size: 12px; + } + + .author { + color: var(--c-text-2); + order: 2; + } + + .date { + order: 2; + } + + .hash { + margin-left: auto; + order: 3; + } + } + </style> +{{ end }} + +{{ define "body" }} + {{ template "header" . }} + <div class="commits"> + {{ range .Commits }} + <div class="row"> + <div class="cell hash"> + <a href="{{ .Href }}">{{ .ShortHash }}</a> + </div> + <div class="cell commit-title"> + <a href="{{ .Href }}">{{ .Subject }}</a> + {{ if .RefNames }} + <div class="ref-badges"> + {{ range .RefNames }} + {{ if or (eq .Kind "Branch") (eq .Kind "Tag") }} + <span class="badge">{{ if eq .Kind "Tag" }}tag: {{ end }}{{ .Name }}</span> + {{ end }} + {{ end }} + </div> + {{ end }} + </div> + <div class="cell author">{{ .Author }}</div> + <div class="cell date">{{ .Date | FormatDate }}</div> + </div> + {{ else}} + <div class="row">(no commits)</div> + {{ end }} + </div> + <div class="pagination"> + {{ if .Page.FirstHref }} + <a class="btn" href="{{ .Page.FirstHref }}">ยซ First</a> + {{ else }} + <span class="btn disabled">ยซ First</span> + {{ end }} + + {{ if .Page.PrevHref }} + <a class="btn" href="{{ .Page.PrevHref }}">โ Newer</a> + {{ else }} + <span class="btn disabled">โ Newer</span> + {{ end }} + + <span class="page-indicator">Page {{ .Page.Page }} of {{ .Page.TotalPages }}</span> + + {{ if .Page.NextHref }} + <a class="btn" href="{{ .Page.NextHref }}">Older โ</a> + {{ else }} + <span class="btn disabled">Older โ</span> + {{ end }} + + {{ if .Page.LastHref }} + <a class="btn" href="{{ .Page.LastHref }}">Last ยป</a> + {{ else }} + <span class="btn disabled">Last ยป</span> + {{ end }} + </div> +{{ end }} diff --git a/internal/templates/css/markdown_dark.css b/internal/templates/css/markdown_dark.css new file mode 100644 index 0000000..0d5d47d --- /dev/null +++ b/internal/templates/css/markdown_dark.css @@ -0,0 +1,1042 @@ +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: 600; +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: 600; + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid #3d444db3; +} + +.markdown-body mark { + background-color: #bb800926; + color: #f0f6fc; +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 2.5rem; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + border-bottom: 1px solid #3d444db3; + height: .25em; + padding: 0; + margin: 1.5rem 0; + background-color: #3d444d; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: #9198a1; + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + font-variant: tabular-nums; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid #1f6feb; + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid #1f6feb; + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 0.25rem; + font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + line-height: 10px; + color: #f0f6fc; + vertical-align: middle; + background-color: #151b23; + border: solid 1px #3d444db3; + border-radius: 6px; + box-shadow: inset 0 -1px 0 #3d444db3; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 1.5rem; + margin-bottom: 1rem; + font-weight: 600; + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: 600; + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid #3d444db3; +} + +.markdown-body h3 { + font-weight: 600; + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: 600; + font-size: 1em; +} + +.markdown-body h5 { + font-weight: 600; + font-size: .875em; +} + +.markdown-body h6 { + font-weight: 600; + font-size: .85em; + color: #9198a1; +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: #9198a1; + border-left: .25em solid #3d444d; +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: 0.5rem !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body > *:first-child { + margin-top: 0 !important; +} + +.markdown-body > *:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: #f85149; +} + +.markdown-body .anchor { + float: left; + padding-right: 0.25rem; + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 1rem; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div > ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li > p { + margin-top: 1rem; +} + +.markdown-body li + li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 1rem; + font-size: 1em; + font-style: italic; + font-weight: 600; +} + +.markdown-body dl dd { + padding: 0 1rem; + margin-bottom: 1rem; +} + +.markdown-body table th { + font-weight: 600; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #3d444d; +} + +.markdown-body table td > :last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: #0d1117; + border-top: 1px solid #3d444db3; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #151b23; +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame > span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid #3d444d; +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: #f0f6fc; +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center > span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right > span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right > span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: #656c7633; + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre > code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 1rem; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 1rem; + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: #f0f6fc; + background-color: #151b23; + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px 0.5rem 9px; + text-align: right; + background: #0d1117; + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: 600; + background: #151b23; + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: #9198a1; + border-top: 1px solid #3d444d; +} + +.markdown-body .footnotes ol { + padding-left: 1rem; +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 1rem; + margin-top: 1rem; +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: calc(0.5rem * -1); + right: calc(0.5rem * -1); + bottom: calc(0.5rem * -1); + left: calc(1.5rem * -1); + pointer-events: none; + content: ""; + border: 2px solid #1f6feb; + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: #f0f6fc; +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body body:has(:modal) { + padding-right: var(--dialog-scrollgutter) !important; +} + +.markdown-body .pl-c { + color: #9198a1; +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: #79c0ff; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #d2a8ff; +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: #f0f6fc; +} + +.markdown-body .pl-ent { + color: #7ee787; +} + +.markdown-body .pl-k { + color: #ff7b72; +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: #a5d6ff; +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: #ffa657; +} + +.markdown-body .pl-bu { + color: #f85149; +} + +.markdown-body .pl-ii { + color: #f0f6fc; + background-color: #8e1519; +} + +.markdown-body .pl-c2 { + color: #f0f6fc; + background-color: #b62324; +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: #7ee787; +} + +.markdown-body .pl-ml { + color: #f2cc60; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: #1f6feb; +} + +.markdown-body .pl-mi { + font-style: italic; + color: #f0f6fc; +} + +.markdown-body .pl-mb { + font-weight: bold; + color: #f0f6fc; +} + +.markdown-body .pl-md { + color: #ffdcd7; + background-color: #67060c; +} + +.markdown-body .pl-mi1 { + color: #aff5b4; + background-color: #033a16; +} + +.markdown-body .pl-mc { + color: #ffdfb6; + background-color: #5a1e02; +} + +.markdown-body .pl-mi2 { + color: #f0f6fc; + background-color: #1158c7; +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: #d2a8ff; +} + +.markdown-body .pl-ba { + color: #9198a1; +} + +.markdown-body .pl-sg { + color: #3d444d; +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: #a5d6ff; +} + +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), +.markdown-body button:focus:not(:focus-visible), +.markdown-body summary:focus:not(:focus-visible), +.markdown-body a:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.markdown-body [tabindex="0"]:focus:not(:focus-visible), +.markdown-body details-dialog:focus:not(:focus-visible) { + outline: none; +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: 400; + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: 400; +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item + .task-list-item { + margin-top: 0.25rem; +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body ul:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body ol:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: 0.5rem 1rem; + margin-bottom: 1rem; + color: inherit; + border-left: .25em solid #3d444d; +} + +.markdown-body .markdown-alert > :first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert > :last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: 500; + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: #1f6feb; +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: #4493f8; +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: #8957e5; +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: #ab7df8; +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: #9e6a03; +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: #d29922; +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: #238636; +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: #3fb950; +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: #da3633; +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: #f85149; +} + +.markdown-body > *:first-child > .heading-element:first-child { + margin-top: 0 !important; +} + +.markdown-body .highlight pre:has(+.zeroclipboard-container) { + min-height: 52px; +} diff --git a/internal/templates/css/markdown_light.css b/internal/templates/css/markdown_light.css new file mode 100644 index 0000000..7c36e19 --- /dev/null +++ b/internal/templates/css/markdown_light.css @@ -0,0 +1,1059 @@ +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: 600; +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: 600; + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid #d1d9e0b3; +} + +.markdown-body mark { + background-color: #fff8c5; + color: #1f2328; +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 2.5rem; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + border-bottom: 1px solid #d1d9e0b3; + height: .25em; + padding: 0; + margin: 1.5rem 0; + background-color: #d1d9e0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: #59636e; + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + font-variant: tabular-nums; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid #0969da; + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid #0969da; + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 0.25rem; + font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + line-height: 10px; + color: #1f2328; + vertical-align: middle; + background-color: #f6f8fa; + border: solid 1px #d1d9e0b3; + border-bottom-color: #d1d9e0b3; + border-radius: 6px; + box-shadow: inset 0 -1px 0 #d1d9e0b3; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 1.5rem; + margin-bottom: 1rem; + font-weight: 600; + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: 600; + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid #d1d9e0b3; +} + +.markdown-body h3 { + font-weight: 600; + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: 600; + font-size: 1em; +} + +.markdown-body h5 { + font-weight: 600; + font-size: .875em; +} + +.markdown-body h6 { + font-weight: 600; + font-size: .85em; + color: #59636e; +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: #59636e; + border-left: .25em solid #d1d9e0; +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: 0.5rem !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body > *:first-child { + margin-top: 0 !important; +} + +.markdown-body > *:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: #d1242f; +} + +.markdown-body .anchor { + float: left; + padding-right: 0.25rem; + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 1rem; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: #1f2328; + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div > ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li > p { + margin-top: 1rem; +} + +.markdown-body li + li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 1rem; + font-size: 1em; + font-style: italic; + font-weight: 600; +} + +.markdown-body dl dd { + padding: 0 1rem; + margin-bottom: 1rem; +} + +.markdown-body table th { + font-weight: 600; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #d1d9e0; +} + +.markdown-body table td > :last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: #ffffff; + border-top: 1px solid #d1d9e0b3; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame > span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid #d1d9e0; +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: #1f2328; +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center > span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right > span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right > span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: #818b981f; + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre > code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 1rem; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 1rem; + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: #1f2328; + background-color: #f6f8fa; + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px 0.5rem 9px; + text-align: right; + background: #ffffff; + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: 600; + background: #f6f8fa; + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: #59636e; + border-top: 1px solid #d1d9e0; +} + +.markdown-body .footnotes ol { + padding-left: 1rem; +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 1rem; + margin-top: 1rem; +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: calc(0.5rem * -1); + right: calc(0.5rem * -1); + bottom: calc(0.5rem * -1); + left: calc(1.5rem * -1); + pointer-events: none; + content: ""; + border: 2px solid #0969da; + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: #1f2328; +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body .pl-c { + color: #59636e; +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: #0550ae; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #6639ba; +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: #1f2328; +} + +.markdown-body .pl-ent { + color: #0550ae; +} + +.markdown-body .pl-k { + color: #cf222e; +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: #0a3069; +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: #953800; +} + +.markdown-body .pl-bu { + color: #82071e; +} + +.markdown-body .pl-ii { + color: #f6f8fa; + background-color: #82071e; +} + +.markdown-body .pl-c2 { + color: #f6f8fa; + background-color: #cf222e; +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: #116329; +} + +.markdown-body .pl-ml { + color: #3b2300; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: #0550ae; +} + +.markdown-body .pl-mi { + font-style: italic; + color: #1f2328; +} + +.markdown-body .pl-mb { + font-weight: bold; + color: #1f2328; +} + +.markdown-body .pl-md { + color: #82071e; + background-color: #ffebe9; +} + +.markdown-body .pl-mi1 { + color: #116329; + background-color: #dafbe1; +} + +.markdown-body .pl-mc { + color: #953800; + background-color: #ffd8b5; +} + +.markdown-body .pl-mi2 { + color: #d1d9e0; + background-color: #0550ae; +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: #8250df; +} + +.markdown-body .pl-ba { + color: #59636e; +} + +.markdown-body .pl-sg { + color: #818b98; +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: #0a3069; +} + +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), +.markdown-body button:focus:not(:focus-visible), +.markdown-body summary:focus:not(:focus-visible), +.markdown-body a:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.markdown-body [tabindex="0"]:focus:not(:focus-visible), +.markdown-body details-dialog:focus:not(:focus-visible) { + outline: none; +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: 400; + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: 400; +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item + .task-list-item { + margin-top: 0.25rem; +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body ul:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body ol:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: 0.5rem 1rem; + margin-bottom: 1rem; + color: inherit; + border-left: .25em solid #d1d9e0; +} + +.markdown-body .markdown-alert > :first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert > :last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: 500; + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: #0969da; +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: #0969da; +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: #8250df; +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: #8250df; +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: #9a6700; +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: #9a6700; +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: #1a7f37; +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: #1a7f37; +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: #cf222e; +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: #d1242f; +} + +.markdown-body > *:first-child > .heading-element:first-child { + margin-top: 0 !important; +} + +.markdown-body .highlight pre:has(+.zeroclipboard-container) { + min-height: 52px; +} diff --git a/internal/templates/file_tree.gohtml b/internal/templates/file_tree.gohtml new file mode 100644 index 0000000..21fc07c --- /dev/null +++ b/internal/templates/file_tree.gohtml @@ -0,0 +1,43 @@ +{{- /*gotype:mokhan.ca/antonmedv/gitmal/pkg/templates.FileTreeParams*/ -}} +{{ define "file_tree" }} + {{ range .Nodes }} + {{ if .IsDir }} + <details open> + <summary class="node"> + <svg aria-hidden="true" focusable="false" width="16" height="16" class="dir"> + <use xlink:href="#dir"></use> + </svg> + <span class="file-name">{{ .Name }}</span> + </summary> + <div class="children"> + {{ template "file_tree" (FileTreeParams .Children) }} + </div> + </details> + {{ else }} + <div class="node"> + <div class="icon" aria-hidden="true"> + {{ if .IsNew }} + <svg aria-hidden="true" focusable="false" width="16" height="16" class="file-added"> + <use xlink:href="#file-added"></use> + </svg> + {{ else if .IsDelete }} + <svg aria-hidden="true" focusable="false" width="16" height="16" class="file-deleted"> + <use xlink:href="#file-deleted"></use> + </svg> + {{ else if .IsRename }} + <svg aria-hidden="true" focusable="false" width="16" height="16" class="file-renamed"> + <use xlink:href="#file-renamed"></use> + </svg> + {{ else }} + <svg aria-hidden="true" focusable="false" width="16" height="16"> + <use xlink:href="#file-modified"></use> + </svg> + {{ end }} + </div> + <a href="#{{.Path}}" class="file-name"> + {{ .Path | BaseName }} + </a> + </div> + {{ end }} + {{ end }} +{{ end }} diff --git a/internal/templates/header.gohtml b/internal/templates/header.gohtml new file mode 100644 index 0000000..4b5af05 --- /dev/null +++ b/internal/templates/header.gohtml @@ -0,0 +1,35 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.HeaderParams*/ -}} +{{ define "header" }} + <div class="header-container"> + <header> + {{ if .Ref }} + <div class="header-ref" aria-label="Ref">{{ .Ref }}</div> + {{ end }} + {{ if .Header }} + <h1 class="title">{{ .Header }}</h1> + {{ else if .Breadcrumbs }} + <nav class="breadcrumbs" aria-label="Breadcrumbs"> + {{ range $i, $b := .Breadcrumbs }} + {{ if gt $i 0 }} + <div>/</div> + {{ end }} + {{ if $b.Href }} + <a href="{{$b.Href}}">{{$b.Name}}</a> + {{ else }} + <h1>{{$b.Name}}</h1> + {{ if $b.IsDir}} + <div>/</div> + {{ end}} + {{ end }} + {{ end }} + </nav> + {{ end }} + <button class="goto-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})"> + <svg aria-hidden="true" focusable="false" width="16" height="16"> + <use xlink:href="#arrow-top"></use> + </svg> + Top + </button> + </header> + </div> +{{ end }} diff --git a/internal/templates/layout.gohtml b/internal/templates/layout.gohtml new file mode 100644 index 0000000..9438396 --- /dev/null +++ b/internal/templates/layout.gohtml @@ -0,0 +1,318 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.LayoutParams*/ -}} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{ .Title }}</title> + <style> + {{ if .Dark }} + :root { + --c-indigo-1: #a8b1ff; + --c-indigo-2: #5c73e7; + --c-indigo-3: #3e63dd; + --c-green: #57ab5a; + --c-red: #e5534b; + --c-yellow: #c69026; + --c-dir: #9198a1; + --c-gray-soft: rgba(101, 117, 133, .16); + --c-bg: #1b1b1f; + --c-bg-alt: #161618; + --c-bg-elv: #202127; + --c-text-1: rgba(255, 255, 245, .86); + --c-text-2: rgba(235, 235, 245, .6); + --c-text-3: rgba(235, 235, 245, .38); + --c-border: #3c3f44; + --c-divider: #2e2e32; + } + + {{ else }} + :root { + --c-indigo-1: #3451b2; + --c-indigo-2: #3a5ccc; + --c-indigo-3: #5672cd; + --c-green: #1a7f37; + --c-red: #c53030; + --c-yellow: #9a6700; + --c-dir: #54aeff; + --c-gray-soft: rgba(142, 150, 170, .14); + --c-bg: #ffffff; + --c-bg-alt: #f6f6f7; + --c-bg-elv: #ffffff; + --c-text-1: rgba(60, 60, 67); + --c-text-2: rgba(60, 60, 67, .78); + --c-text-3: rgba(60, 60, 67, .56); + --c-border: #c2c2c4; + --c-divider: #e2e2e3; + } + + {{ end }} + + :root { + --c-brand-1: var(--c-indigo-1); + --c-brand-2: var(--c-indigo-2); + --c-brand-3: var(--c-indigo-3); + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --code-line-height: 20px; + --code-font-size: 12px; + --code-color: var(--c-brand-1); + --code-bg: var(--c-gray-soft); + --code-block-bg: var(--c-bg-alt); + --code-block-color: var(--c-text-1); + --header-height: 46px; + --border-radius: 6px; + --max-content-width: 1470px; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + padding: 0; + font-family: var(--font-family), sans-serif; + font-size: 14px; + line-height: 1; + color: var(--c-text-1); + background-color: var(--c-bg); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; + min-height: 100vh; + display: flex; + flex-direction: column; + } + + .nowrap { + white-space: nowrap; + } + + h1 { + margin-inline: 0; + margin-block: 16px; + font-size: 20px; + font-weight: 600; + } + + a { + color: var(--c-brand-1); + font-weight: 500; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration: inherit; + touch-action: manipulation; + } + + a:hover { + color: var(--c-brand-2); + text-decoration: underline; + } + + .menu { + background-color: var(--c-bg-alt); + border-bottom: 1px solid var(--c-divider); + overflow-x: auto; + } + + .menu-content { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + padding-inline: 16px; + max-width: var(--max-content-width); + margin-inline: auto; + } + + .menu-item { + display: flex; + align-items: center; + border-bottom: 2px solid transparent; + height: 56px; + padding-inline: 8px; + } + + .menu-item a { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + color: var(--c-text-1); + padding: 8px 10px; + border-radius: 4px; + } + + .menu-item a:hover { + background-color: var(--c-bg-elv); + text-decoration: none; + } + + .menu-item.selected { + border-bottom-color: var(--c-brand-1); + } + + .project-name { + font-weight: 600; + font-size: 16px; + margin-inline: 16px; + color: var(--c-text-1); + text-decoration: none; + } + + main { + flex-grow: 1; + width: 100%; + max-width: var(--max-content-width); + margin: 16px auto; + } + + .main-content { + padding-inline: 16px; + } + + footer { + padding: 12px 16px; + background-color: var(--c-bg-alt); + border-top: 1px solid var(--c-divider); + color: var(--c-text-3); + font-size: 12px; + text-align: center; + } + + .header-container { + container-type: scroll-state; + position: sticky; + top: 0; + } + + .header-container { + @container scroll-state(stuck: top) { + header { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .goto-top { + display: flex; + } + } + } + + header { + display: flex; + flex-direction: row; + align-items: center; + min-height: var(--header-height); + padding-inline: 16px; + background: var(--c-bg-alt); + border: 1px solid var(--c-border); + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + } + + header h1 { + word-break: break-all; + font-weight: 600; + font-size: 16px; + margin: 0; + padding: 0; + } + + .header-ref { + color: var(--c-text-2); + border: 1px solid var(--c-border); + border-radius: 6px; + padding: 6px 10px; + margin-right: 10px; + margin-left: -6px; + } + + header .path { + font-size: 16px; + } + + .breadcrumbs { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 6px; + font-size: 16px; + } + + .breadcrumbs a { + word-break: break-all; + } + + .goto-top { + display: none; + margin-left: auto; + padding: 6px 10px; + background: none; + border: none; + border-radius: 6px; + gap: 4px; + align-items: center; + color: var(--c-text-1); + cursor: pointer; + } + + .goto-top:hover { + background: var(--c-bg-elv); + } + </style> + {{ template "head" . }} +</head> +<body> +{{ template "svg" . }} +<div class="menu"> + <div class="menu-content"> + <a href="{{ .RootHref }}index.html" class="project-name">{{ .Name }}</a> + <div class="menu-item {{ if eq .Selected "code" }}selected{{ end }}"> + <a href="{{ .RootHref }}blob/{{ .CurrentRefDir }}/index.html"> + <svg aria-hidden="true" width="16" height="16"> + <use xlink:href="#code"></use> + </svg> + Code + </a> + </div> + <div class="menu-item {{ if eq .Selected "branches" }}selected{{ end }}"> + <a href="{{ .RootHref }}branches.html"> + <svg aria-hidden="true" focusable="false" width="16" height="16"> + <use xlink:href="#branch"></use> + </svg> + Branches + </a> + </div> + <div class="menu-item {{ if eq .Selected "tags" }}selected{{ end }}"> + <a href="{{ .RootHref }}tags.html"> + <svg aria-hidden="true" focusable="false" width="16" height="16"> + <use xlink:href="#tag"></use> + </svg> + Tags + </a> + </div> + <div class="menu-item {{ if eq .Selected "commits" }}selected{{ end }}"> + <a href="{{ .RootHref }}commits/{{ .CurrentRefDir }}/index.html"> + <svg aria-hidden="true" focusable="false" width="16" height="16"> + <use xlink:href="#commit"></use> + </svg> + Commits + </a> + </div> + </div> +</div> +<main> + <div class="main-content"> + {{ template "body" . }} + </div> +</main> +<footer> +</footer> +</body> +</html> diff --git a/internal/templates/list.gohtml b/internal/templates/list.gohtml new file mode 100644 index 0000000..9780504 --- /dev/null +++ b/internal/templates/list.gohtml @@ -0,0 +1,145 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.ListParams*/ -}} +{{ define "head" }} + <style> + .files { + border: 1px solid var(--c-border); + border-top: none; + width: 100%; + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + overflow-x: auto; + } + + .files .dir { + color: var(--c-dir); + } + + .files a { + color: var(--c-text-1); + } + + .files a:hover { + color: var(--c-brand-2); + } + + .row { + display: flex; + height: 40px; + border-bottom: 1px solid var(--c-border); + padding-inline: 16px; + gap: 16px; + } + + .row:last-child { + border-bottom: none; + } + + .row:hover { + background-color: var(--c-bg-alt); + } + + .cell { + flex: 1; + display: flex; + gap: 8px; + align-items: center; + white-space: nowrap; + } + + .cell:not(:first-child) { + justify-content: flex-end; + max-width: 100px; + } + + @media (max-width: 767px) { + .file-mode { + display: none; + } + } + + {{ if .Readme }} + .markdown-container { + padding-inline: 16px; + border: 1px solid var(--c-divider); + border-radius: 8px; + overflow-x: auto; + margin-top: 16px; + } + + .markdown-body { + width: 100%; + min-width: 200px; + max-width: 980px; + margin: 0 auto; + padding: 32px; + word-wrap: break-word; + font-size: 16px; + line-height: 1.5; + } + + @media (max-width: 767px) { + .markdown-body { + padding: 16px; + } + } + + {{.CSSMarkdown}} + {{ end }} + </style> +{{ end }} + +{{ define "body" }} + {{ template "header" . }} + + <div class="files"> + {{ if .ParentHref }} + <div class="row"> + <div class="cell"> + <svg aria-hidden="true" focusable="false" width="16" height="16" class="dir"> + <use xlink:href="#dir"></use> + </svg> + <a href="{{ .ParentHref }}">..</a> + </div> + </div> + {{ end }} + + {{ if or .Dirs .Files }} + {{ range .Dirs }} + <div class="row"> + <div class="cell"> + <svg aria-hidden="true" focusable="false" width="16" height="16" class="dir"> + <use xlink:href="#dir"></use> + </svg> + <a href="{{ .Href }}">{{ .Name }}</a> + </div> + </div> + {{ end }} + + {{ range .Files }} + <div class="row"> + <div class="cell"> + <svg aria-hidden="true" focusable="false" width="16" height="16"> + <use xlink:href="#file"></use> + </svg> + <a href="{{ .Href }}">{{ .Name }}</a> + </div> + <div class="cell file-mode">{{ .Mode }}</div> + <div class="cell file-size">{{ .Size }}</div> + </div> + {{ end }} + {{ else }} + <div class="row"> + <div class="cell">(empty)</div> + </div> + {{ end }} + </div> + + {{ if .Readme }} + <div class="markdown-container"> + <div class="markdown-body"> + {{ .Readme }} + </div> + </div> + {{ end }} +{{ end }} + diff --git a/internal/templates/markdown.gohtml b/internal/templates/markdown.gohtml new file mode 100644 index 0000000..7827950 --- /dev/null +++ b/internal/templates/markdown.gohtml @@ -0,0 +1,45 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.MarkdownParams*/ -}} +{{ define "head" }} + <style> + [id] { + scroll-margin-top: var(--header-height); + } + + .markdown-container { + border: 1px solid var(--c-divider); + border-top: none; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + overflow-x: auto; + } + + .markdown-body { + width: 100%; + min-width: 200px; + max-width: 980px; + margin: 0 auto; + padding: 32px; + word-wrap: break-word; + font-size: 16px; + line-height: 1.5; + } + + @media (max-width: 767px) { + .markdown-body { + padding: 16px; + } + } + + {{.CSSMarkdown}} + </style> +{{ end }} + +{{ define "body" }} + {{ template "header" . }} + <article class="markdown-container"> + <div class="markdown-body"> + {{ .Content }} + </div> + </article> +{{ end }} + diff --git a/internal/templates/preview.gohtml b/internal/templates/preview.gohtml new file mode 100644 index 0000000..bc6f898 --- /dev/null +++ b/internal/templates/preview.gohtml @@ -0,0 +1,144 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.PreviewParams*/ -}} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Chroma Themes Preview</title> + <style> + * { + box-sizing: border-box; + } + + body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: #ffffff; + color: #3c3c43; + line-height: 1.4; + } + + header { + position: sticky; + top: 0; + background: #f6f6f7; + border-bottom: 1px solid #c2c2c4; + padding: 12px 16px; + z-index: 10; + } + + .meta { + color: rgba(60, 60, 67, .78); + font-size: 12px + } + + main { + padding: 16px + } + + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + gap: 16px; + justify-content: center; + } + + .card { + border: 1px solid #c2c2c4; + border-radius: 10px; + overflow: hidden; + background: #fff; + } + + /* Dark tone variant for theme cards */ + .card.dark { + background: #1b1b1f; /* dark bg */ + color: rgba(255, 255, 245, .86); /* light text */ + border-color: #3c3f44; /* dark border */ + } + + .card h2 { + margin: 0; + padding: 10px 12px; + font-size: 14px; + border-bottom: 1px solid #c2c2c4; + display: flex; + justify-content: space-between; + align-items: center; + } + + .card.dark h2 { + border-bottom-color: #3c3f44; + } + + .badge { + font-size: 11px; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid #c2c2c4; + color: rgba(60, 60, 67, .78); + background: #ffffff; + } + + .card.dark .badge { + border-color: #3c3f44; + color: rgba(235, 235, 245, .6); + background: #1b1b1f; + } + + .sample { + padding: 12px; + } + + pre { + margin: 0; + padding: 8px 16px; + overflow-x: auto; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + tab-size: 4; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, monospace; + border-radius: 6px; + background-color: #f6f6f7; + } + + .dark pre { + background-color: #161618; + } + + pre > code { + display: block; + padding: 0 16px; + width: fit-content; + min-width: 100%; + line-height: 20px; + font-size: 12px; + color: rgba(60, 60, 67); + } + + .dark pre > code { + color: rgba(255, 255, 245, .86); + } + </style> +</head> +<body> +<header> + <div><strong>Chroma themes preview</strong></div> + <div class="meta">Showing {{ .Count }} themes. Use <code>--theme <name></code> in the app.</div> +</header> +<main> + <div class="grid"> + {{ range .Themes }} + <section class="card {{ .Tone }}"> + <h2><span>{{ .Name }}</span><span class="badge">{{ .Tone }}</span></h2> + <div class="sample"> + {{ .HTML }} + </div> + </section> + {{ end }} + </div> +</main> +</body> +</html> diff --git a/internal/templates/svg.gohtml b/internal/templates/svg.gohtml new file mode 100644 index 0000000..833af42 --- /dev/null +++ b/internal/templates/svg.gohtml @@ -0,0 +1,50 @@ +{{ define "svg" }} + <svg style="display: none" aria-hidden="true" focusable="false"> + <symbol id="dir" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/> + </symbol> + <symbol id="file" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/> + </symbol> + <symbol id="file-added" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073Zm4.48 3.758a.75.75 0 0 1 .755.745l.01 1.497h1.497a.75.75 0 0 1 0 1.5H9v1.507a.75.75 0 0 1-1.5 0V9.005l-1.502.01a.75.75 0 0 1-.01-1.5l1.507-.01-.01-1.492a.75.75 0 0 1 .745-.755Z"/> + </symbol> + <symbol id="file-modified" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"/> + </symbol> + <symbol id="file-deleted" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073Zm4.5 6h2.242a.75.75 0 0 1 0 1.5h-2.24l-2.254.015a.75.75 0 0 1-.01-1.5Z"/> + </symbol> + <symbol id="file-renamed" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-3.5a.75.75 0 0 1 0-1.5h3.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073H3.75a.25.25 0 0 0-.25.25v6.5a.75.75 0 0 1-1.5 0v-6.5Z"/> + <path fill="currentColor" + d="m5.427 15.573 3.146-3.146a.25.25 0 0 0 0-.354L5.427 8.927A.25.25 0 0 0 5 9.104V11.5H.75a.75.75 0 0 0 0 1.5H5v2.396c0 .223.27.335.427.177Z"/> + </symbol> + <symbol id="arrow-top" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M3.47 7.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L9 4.81v7.44a.75.75 0 0 1-1.5 0V4.81L4.53 7.78a.75.75 0 0 1-1.06 0Z"></path> + </symbol> + <symbol id="commit" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"></path> + </symbol> + <symbol id="branch" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path> + </symbol> + <symbol id="tag" viewBox="0 0 16 16"> + <path fill="currentColor" + d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"></path> + </symbol> + <symbol id="code" viewBox="0 0 16 16"> + <path fill="currentColor" + d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"></path> + </symbol> + </svg> +{{ end }} diff --git a/internal/templates/tags.gohtml b/internal/templates/tags.gohtml new file mode 100644 index 0000000..97b77b0 --- /dev/null +++ b/internal/templates/tags.gohtml @@ -0,0 +1,71 @@ +{{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.TagsParams*/ -}} +{{ define "head" }} + <style> + .tags { + border: 1px solid var(--c-border); + border-radius: var(--border-radius); + overflow: hidden; + } + + .tag-row { + display: flex; + align-items: center; + gap: 12px; + height: 44px; + padding-inline: 16px; + border-bottom: 1px solid var(--c-border); + background-color: var(--c-bg-elv); + } + + .tag-row:last-child { + border-bottom: none; + } + + .cell { + display: flex; + gap: 8px; + align-items: center; + } + + .tag-title { + flex: 1; + } + + .tag-row a { + color: var(--c-text-1); + } + + .tag-row a:hover { + color: var(--c-brand-2); + text-decoration: none; + } + + .date { + font-family: var(--font-family-mono), monospace; + font-size: 12px; + color: var(--c-text-2); + } + + .hash a { + font-family: var(--font-family-mono), monospace; + color: var(--c-text-2); + } + </style> +{{ end }} + +{{ define "body" }} + <h1>Tags</h1> + <div class="tags"> + {{ if .Tags }} + {{ range .Tags }} + <div class="tag-row"> + <div class="cell tag-title"><a href="commit/{{ .CommitHash }}.html">{{ .Name }}</a></div> + <div class="cell date">{{ .Date | FormatDate }}</div> + <div class="cell hash"><a href="commit/{{ .CommitHash }}.html">{{ ShortHash .CommitHash }}</a></div> + </div> + {{ end }} + {{ else }} + <div class="tag-row">(no tags)</div> + {{ end }} + </div> +{{ end }} diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..6cd37a6 --- /dev/null +++ b/internal/templates/templates.go @@ -0,0 +1,213 @@ +package templates + +import ( + "embed" + _ "embed" + . "html/template" + "path/filepath" + "time" + + "mokhan.ca/antonmedv/gitmal/internal/git" +) + +var funcs = FuncMap{ + "BaseName": filepath.Base, + "FormatDate": func(date time.Time) HTML { + return HTML(date.Format(`<span class="nowrap">2006-01-02</span> <span class="nowrap">15:04:05</span>`)) + }, + "ShortHash": func(hash string) string { + return hash[:7] + }, + "FileTreeParams": func(node []*FileTree) FileTreeParams { + return FileTreeParams{Nodes: node} + }, +} + +//go:embed css/markdown_light.css +var CSSMarkdownLight string + +//go:embed css/markdown_dark.css +var CSSMarkdownDark string + +//go:embed layout.gohtml header.gohtml file_tree.gohtml svg.gohtml +var layoutContent embed.FS +var layout = Must(New("layout").Funcs(funcs).ParseFS(layoutContent, "*.gohtml")) + +//go:embed blob.gohtml +var blobContent string +var BlobTemplate = Must(Must(layout.Clone()).Parse(blobContent)) + +//go:embed markdown.gohtml +var markdownContent string +var MarkdownTemplate = Must(Must(layout.Clone()).Parse(markdownContent)) + +//go:embed list.gohtml +var listContent string +var ListTemplate = Must(Must(layout.Clone()).Parse(listContent)) + +//go:embed branches.gohtml +var branchesContent string +var BranchesTemplate = Must(Must(layout.Clone()).Parse(branchesContent)) + +//go:embed tags.gohtml +var tagsContent string +var TagsTemplate = Must(Must(layout.Clone()).Parse(tagsContent)) + +//go:embed commits_list.gohtml +var commitsListContent string +var CommitsListTemplate = Must(Must(layout.Clone()).Parse(commitsListContent)) + +//go:embed commit.gohtml +var commitContent string +var CommitTemplate = Must(Must(layout.Clone()).Parse(commitContent)) + +//go:embed preview.gohtml +var previewContent string +var PreviewTemplate = Must(New("preview").Parse(previewContent)) + +type LayoutParams struct { + Title string + Name string + Dark bool + CSSMarkdown CSS + RootHref string + CurrentRefDir string + Selected string +} + +type HeaderParams struct { + Ref git.Ref + Header string + Breadcrumbs []Breadcrumb +} + +type Breadcrumb struct { + Name string + Href string + IsDir bool +} + +type BlobParams struct { + LayoutParams + HeaderParams + CSS CSS + Blob git.Blob + IsImage bool + IsBinary bool + Content HTML +} + +type MarkdownParams struct { + LayoutParams + HeaderParams + Blob git.Blob + Content HTML +} + +type ListParams struct { + LayoutParams + HeaderParams + Ref git.Ref + ParentHref string + Dirs []ListEntry + Files []ListEntry + Readme HTML +} + +type ListEntry struct { + Name string + Href string + IsDir bool + Mode string + Size string +} + +type BranchesParams struct { + LayoutParams + Branches []BranchEntry +} + +type BranchEntry struct { + Name string + Href string + IsDefault bool + CommitsHref string +} + +type TagsParams struct { + LayoutParams + Tags []git.Tag +} + +type Pagination struct { + Page int + TotalPages int + PrevHref string + NextHref string + FirstHref string + LastHref string +} + +type CommitsListParams struct { + LayoutParams + HeaderParams + Ref git.Ref + Commits []git.Commit + Page Pagination +} + +type CommitParams struct { + LayoutParams + Commit git.Commit + DiffCSS CSS + FileTree []*FileTree + FileViews []FileView +} + +type FileTreeParams struct { + Nodes []*FileTree +} + +// FileTree represents a directory or file in a commit's changed files tree. +// For directories, IsDir is true and Children contains nested nodes. +// For files, status flags indicate the type of change. +type FileTree struct { + Name string + Path string + IsDir bool + Children []*FileTree + + // File status (applicable when IsDir == false) + IsNew bool + IsDelete bool + IsRename bool + OldName string + NewName string + // Anchor id for this file (empty for directories) + Anchor string +} + +// FileView represents a single file section on the commit page with its +// pre-rendered HTML diff and metadata used by the template. +type FileView struct { + Path string + OldName string + NewName string + IsNew bool + IsDelete bool + IsRename bool + IsBinary bool + HasChanges bool + HTML HTML // pre-rendered HTML for diff of this file +} + +type PreviewCard struct { + Name string + Tone string + HTML HTML +} + +type PreviewParams struct { + Count int + Themes []PreviewCard +} |
