basic template

This commit is contained in:
eyedeekay
2025-05-05 21:55:21 -04:00
parent 34f09b89a0
commit 6318a10bf4
10 changed files with 1857 additions and 0 deletions

380
pkg/generator/generator.go Normal file
View File

@ -0,0 +1,380 @@
package generator
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/template"
"time"
"github.com/go-i2p/go-gh-page/pkg/git"
"github.com/go-i2p/go-gh-page/pkg/templates"
"github.com/go-i2p/go-gh-page/pkg/utils"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
// GenerationResult contains information about the generated site
type GenerationResult struct {
DocsCount int
ImagesCount int
SiteStructure string
}
// Generator handles the site generation
type Generator struct {
repoData *git.RepositoryData
outputDir string
templateCache map[string]*template.Template
}
// PageData contains the data passed to HTML templates
type PageData struct {
RepoOwner string
RepoName string
RepoFullName string
Description string
CommitCount int
LastUpdate string
License string
RepoURL string
ReadmeHTML string
Contributors []git.Contributor
// Navigation
DocsPages []utils.DocPage
// Current page info
CurrentPage string
PageTitle string
PageContent string
// Generation info
GeneratedAt string
}
// NewGenerator creates a new site generator
func NewGenerator(repoData *git.RepositoryData, outputDir string) *Generator {
return &Generator{
repoData: repoData,
outputDir: outputDir,
templateCache: make(map[string]*template.Template),
}
}
// GenerateSite generates the complete static site
func (g *Generator) GenerateSite() (*GenerationResult, error) {
result := &GenerationResult{}
// Create docs directory
docsDir := filepath.Join(g.outputDir, "docs")
if err := os.MkdirAll(docsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create docs directory: %w", err)
}
// Create image directory if needed
imagesDir := filepath.Join(g.outputDir, "images")
if err := os.MkdirAll(imagesDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create images directory: %w", err)
}
// Parse all templates first
if err := g.parseTemplates(); err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}
// Copy image files to output directory
for relativePath, sourcePath := range g.repoData.ImageFiles {
destPath := filepath.Join(g.outputDir, "images", filepath.Base(relativePath))
if err := copyFile(sourcePath, destPath); err != nil {
return nil, fmt.Errorf("failed to copy image %s: %w", relativePath, err)
}
result.ImagesCount++
}
// Prepare the list of documentation pages for navigation
var docsPages []utils.DocPage
for path := range g.repoData.MarkdownFiles {
// Skip README as it's on the main page
if isReadmeFile(filepath.Base(path)) {
continue
}
title := utils.GetTitleFromMarkdown(g.repoData.MarkdownFiles[path])
if title == "" {
title = utils.PrettifyFilename(filepath.Base(path))
}
outputPath := utils.GetOutputPath(path, "docs")
docsPages = append(docsPages, utils.DocPage{
Title: title,
Path: outputPath,
})
}
// Sort docsPages by title for consistent navigation
utils.SortDocPagesByTitle(docsPages)
// Generate main index page
if err := g.generateMainPage(docsPages); err != nil {
return nil, fmt.Errorf("failed to generate main page: %w", err)
}
// Generate documentation pages
for path, content := range g.repoData.MarkdownFiles {
// Skip README as it's on the main page
if isReadmeFile(filepath.Base(path)) {
continue
}
if err := g.generateDocPage(path, content, docsPages); err != nil {
return nil, fmt.Errorf("failed to generate doc page for %s: %w", path, err)
}
result.DocsCount++
}
// Generate site structure summary
var buffer bytes.Buffer
buffer.WriteString(g.outputDir + "/\n")
buffer.WriteString(" ├── index.html\n")
buffer.WriteString(" ├── docs/\n")
if len(docsPages) > 0 {
for i, page := range docsPages {
prefix := " │ ├── "
if i == len(docsPages)-1 {
prefix = " │ └── "
}
buffer.WriteString(prefix + filepath.Base(page.Path) + "\n")
}
} else {
buffer.WriteString(" │ └── (empty)\n")
}
if result.ImagesCount > 0 {
buffer.WriteString(" └── images/\n")
buffer.WriteString(" └── ... (" + fmt.Sprintf("%d", result.ImagesCount) + " files)\n")
} else {
buffer.WriteString(" └── images/\n")
buffer.WriteString(" └── (empty)\n")
}
result.SiteStructure = buffer.String()
return result, nil
}
// parseTemplates parses all the HTML templates
func (g *Generator) parseTemplates() error {
// Parse main template
mainTmpl, err := template.New("main").Parse(templates.MainTemplate)
if err != nil {
return fmt.Errorf("failed to parse main template: %w", err)
}
g.templateCache["main"] = mainTmpl
// Parse documentation template
docTmpl, err := template.New("doc").Parse(templates.DocTemplate)
if err != nil {
return fmt.Errorf("failed to parse doc template: %w", err)
}
g.templateCache["doc"] = docTmpl
return nil
}
// generateMainPage creates the main index.html
func (g *Generator) generateMainPage(docsPages []utils.DocPage) error {
// Prepare data for template
data := PageData{
RepoOwner: g.repoData.Owner,
RepoName: g.repoData.Name,
RepoFullName: g.repoData.Owner + "/" + g.repoData.Name,
Description: g.repoData.Description,
CommitCount: g.repoData.CommitCount,
License: g.repoData.License,
RepoURL: g.repoData.URL,
LastUpdate: g.repoData.LastCommitDate.Format("January 2, 2006"),
ReadmeHTML: renderMarkdown(g.repoData.ReadmeContent),
Contributors: g.repoData.Contributors,
DocsPages: docsPages,
CurrentPage: "index.html",
PageTitle: g.repoData.Owner + "/" + g.repoData.Name,
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
}
// Render template
var buf bytes.Buffer
if err := g.templateCache["main"].Execute(&buf, data); err != nil {
return fmt.Errorf("failed to execute main template: %w", err)
}
// Write to file
outputPath := filepath.Join(g.outputDir, "index.html")
if err := os.WriteFile(outputPath, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("failed to write index.html: %w", err)
}
return nil
}
// generateDocPage creates an HTML page for a markdown file
func (g *Generator) generateDocPage(path, content string, docsPages []utils.DocPage) error {
// Get the title from the markdown content
title := utils.GetTitleFromMarkdown(content)
if title == "" {
title = utils.PrettifyFilename(filepath.Base(path))
}
// Process relative links in the markdown
processedContent := utils.ProcessRelativeLinks(content, path, g.repoData.Owner, g.repoData.Name)
// Process image links to point to our local images
processedContent = processImageLinks(processedContent, path)
// Render markdown to HTML
contentHTML := renderMarkdown(processedContent)
// Create a copy of docsPages with current page marked as active
currentDocsPages := make([]utils.DocPage, len(docsPages))
copy(currentDocsPages, docsPages)
outputPath := utils.GetOutputPath(path, "docs")
for i := range currentDocsPages {
if currentDocsPages[i].Path == outputPath {
currentDocsPages[i].IsActive = true
}
}
// Prepare data for template
data := PageData{
RepoOwner: g.repoData.Owner,
RepoName: g.repoData.Name,
RepoFullName: g.repoData.Owner + "/" + g.repoData.Name,
Description: g.repoData.Description,
CommitCount: g.repoData.CommitCount,
License: g.repoData.License,
RepoURL: g.repoData.URL,
LastUpdate: g.repoData.LastCommitDate.Format("January 2, 2006"),
DocsPages: currentDocsPages,
CurrentPage: outputPath,
PageTitle: title + " - " + g.repoData.Owner + "/" + g.repoData.Name,
PageContent: contentHTML,
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
}
// Render template
var buf bytes.Buffer
if err := g.templateCache["doc"].Execute(&buf, data); err != nil {
return fmt.Errorf("failed to execute doc template: %w", err)
}
// Ensure output directory exists
outPath := filepath.Join(g.outputDir, outputPath)
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", outPath, err)
}
// Write to file
if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", outPath, err)
}
return nil
}
// isReadmeFile checks if a file is a README
func isReadmeFile(filename string) bool {
lowerFilename := strings.ToLower(filename)
return strings.HasPrefix(lowerFilename, "readme.")
}
// renderMarkdown converts markdown content to HTML
func renderMarkdown(md string) string {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse([]byte(md))
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return string(markdown.Render(doc, renderer))
}
// processImageLinks updates image links to point to our local images
func processImageLinks(content, filePath string) string {
// Replace image links with links to our local images directory
re := utils.GetImageLinkRegex()
baseDir := filepath.Dir(filePath)
return re.ReplaceAllStringFunc(content, func(match string) string {
submatch := re.FindStringSubmatch(match)
if len(submatch) < 3 {
return match
}
altText := submatch[1]
imagePath := submatch[2]
// Skip absolute URLs
if strings.HasPrefix(imagePath, "http") {
return match
}
// Make the path relative to the root
if !strings.HasPrefix(imagePath, "/") {
// Handle ./image.jpg style paths
if strings.HasPrefix(imagePath, "./") {
imagePath = imagePath[2:]
}
// If in a subdirectory, make path relative to root
if baseDir != "." {
imagePath = filepath.Join(baseDir, imagePath)
}
} else {
// Remove leading slash if any
imagePath = strings.TrimPrefix(imagePath, "/")
}
// Create a path to our local images directory
localPath := "../images/" + filepath.Base(imagePath)
return fmt.Sprintf("![%s](%s)", altText, localPath)
})
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
// Open source file
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
// Create destination file
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
// Copy the contents
_, err = io.Copy(destFile, sourceFile)
return err
}

350
pkg/git/git.go Normal file
View File

@ -0,0 +1,350 @@
package git
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
// RepositoryData contains all the information about a repository
type RepositoryData struct {
Owner string
Name string
Description string
URL string
// Content
ReadmeContent string
ReadmePath string
MarkdownFiles map[string]string // path -> content
// Stats from git
Contributors []Contributor
CommitCount int
LastCommitDate time.Time
// License information if available
License string
// Set of image paths in the repository (to copy to output)
ImageFiles map[string]string // path -> full path on disk
}
// Contributor represents a repository contributor
type Contributor struct {
Name string
Email string
Commits int
AvatarURL string
}
// CloneRepository clones a Git repository to the specified directory
func CloneRepository(url, destination, branch string) (*git.Repository, error) {
// Check if repository already exists
if _, err := os.Stat(destination); err == nil {
// Directory exists, try to open repository
repo, err := git.PlainOpen(destination)
if err == nil {
fmt.Println("Using existing repository clone")
return repo, nil
}
// If error, remove directory and clone fresh
os.RemoveAll(destination)
}
// Clone options
options := &git.CloneOptions{
URL: url,
}
// Set branch if not default
if branch != "main" && branch != "master" {
options.ReferenceName = plumbing.NewBranchReferenceName(branch)
}
// Clone the repository
return git.PlainClone(destination, false, options)
}
// GetRepositoryData extracts information from a cloned repository
func GetRepositoryData(repo *git.Repository, owner, name, repoPath string) (*RepositoryData, error) {
repoData := &RepositoryData{
Owner: owner,
Name: name,
URL: fmt.Sprintf("https://github.com/%s/%s", owner, name),
MarkdownFiles: make(map[string]string),
ImageFiles: make(map[string]string),
}
// Get the repository description from the repository
config, err := repo.Config()
if err == nil && config != nil {
repoData.Description = config.Raw.Section("").Option("description")
}
// Get HEAD reference
ref, err := repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD reference: %w", err)
}
// Get commit history
cIter, err := repo.Log(&git.LogOptions{From: ref.Hash()})
if err != nil {
return nil, fmt.Errorf("failed to get commit history: %w", err)
}
// Process commits
contributors := make(map[string]*Contributor)
err = cIter.ForEach(func(c *object.Commit) error {
// Count commits
repoData.CommitCount++
// Update last commit date if needed
if repoData.LastCommitDate.IsZero() || c.Author.When.After(repoData.LastCommitDate) {
repoData.LastCommitDate = c.Author.When
}
// Track contributors
email := c.Author.Email
if _, exists := contributors[email]; !exists {
contributors[email] = &Contributor{
Name: c.Author.Name,
Email: email,
Commits: 0,
// GitHub avatar URL uses MD5 hash of email, which we'd generate here
// but for simplicity we'll use a default avatar
AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/0?v=4"),
}
}
contributors[email].Commits++
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to process commits: %w", err)
}
// Convert contributors map to slice and sort by commit count
for _, contributor := range contributors {
repoData.Contributors = append(repoData.Contributors, *contributor)
}
// Sort contributors by commit count (we'll implement this in utils)
sortContributorsByCommits(repoData.Contributors)
// If we have more than 5 contributors, limit to top 5
if len(repoData.Contributors) > 5 {
repoData.Contributors = repoData.Contributors[:5]
}
// Walk the repository to find markdown and image files
err = filepath.WalkDir(repoPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip .git directory
if d.IsDir() && d.Name() == ".git" {
return filepath.SkipDir
}
// Skip other common directories we don't want
if d.IsDir() && (d.Name() == "node_modules" || d.Name() == "vendor" || d.Name() == ".github") {
return filepath.SkipDir
}
// Process files
if !d.IsDir() {
relativePath, err := filepath.Rel(repoPath, path)
if err != nil {
return err
}
// Handle markdown files
if isMarkdownFile(d.Name()) {
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}
// Store markdown content
repoData.MarkdownFiles[relativePath] = string(content)
// Check if this is a README file
if isReadmeFile(d.Name()) && (repoData.ReadmePath == "" || relativePath == "README.md") {
repoData.ReadmePath = relativePath
repoData.ReadmeContent = string(content)
}
fmt.Printf("Found markdown file: %s\n", relativePath)
}
// Handle image files
if isImageFile(d.Name()) {
repoData.ImageFiles[relativePath] = path
fmt.Printf("Found image file: %s\n", relativePath)
}
// Check for license file
if isLicenseFile(d.Name()) && repoData.License == "" {
content, err := os.ReadFile(path)
if err == nil {
// Try to determine license type from content
licenseType := detectLicenseType(string(content))
if licenseType != "" {
repoData.License = licenseType
} else {
repoData.License = "License"
}
}
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk repository: %w", err)
}
// If we didn't find a description, try to extract from README
if repoData.Description == "" && repoData.ReadmeContent != "" {
repoData.Description = extractDescriptionFromReadme(repoData.ReadmeContent)
}
return repoData, nil
}
// isMarkdownFile checks if a filename has a markdown extension
func isMarkdownFile(filename string) bool {
extensions := []string{".md", ".markdown", ".mdown", ".mkdn"}
lowerFilename := strings.ToLower(filename)
for _, ext := range extensions {
if strings.HasSuffix(lowerFilename, ext) {
return true
}
}
return false
}
// isReadmeFile checks if a file is a README
func isReadmeFile(filename string) bool {
lowerFilename := strings.ToLower(filename)
return strings.HasPrefix(lowerFilename, "readme.") && isMarkdownFile(filename)
}
// isImageFile checks if a filename has an image extension
func isImageFile(filename string) bool {
extensions := []string{".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"}
lowerFilename := strings.ToLower(filename)
for _, ext := range extensions {
if strings.HasSuffix(lowerFilename, ext) {
return true
}
}
return false
}
// isLicenseFile checks if a file is likely a license file
func isLicenseFile(filename string) bool {
lowerFilename := strings.ToLower(filename)
return lowerFilename == "license" || lowerFilename == "license.md" ||
lowerFilename == "license.txt" || lowerFilename == "copying"
}
// detectLicenseType tries to determine the license type from its content
func detectLicenseType(content string) string {
content = strings.ToLower(content)
// Check for common license types
if strings.Contains(content, "mit license") {
return "MIT License"
} else if strings.Contains(content, "apache license") {
return "Apache License"
} else if strings.Contains(content, "gnu general public license") ||
strings.Contains(content, "gpl") {
return "GPL License"
} else if strings.Contains(content, "bsd") {
return "BSD License"
} else if strings.Contains(content, "mozilla public license") {
return "Mozilla Public License"
}
return ""
}
// extractDescriptionFromReadme tries to get a short description from README
func extractDescriptionFromReadme(content string) string {
// Try to find the first paragraph after the title
re := regexp.MustCompile(`(?m)^#\s+.+\n+(.+)`)
matches := re.FindStringSubmatch(content)
if len(matches) > 1 {
// Return first paragraph, up to 150 chars
desc := matches[1]
if len(desc) > 150 {
desc = desc[:147] + "..."
}
return desc
}
// If no match, just take the first non-empty line
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
if len(line) > 150 {
line = line[:147] + "..."
}
return line
}
}
return ""
}
// sortContributorsByCommits sorts contributors by commit count (descending)
func sortContributorsByCommits(contributors []Contributor) {
// Simple bubble sort implementation
for i := 0; i < len(contributors); i++ {
for j := i + 1; j < len(contributors); j++ {
if contributors[i].Commits < contributors[j].Commits {
contributors[i], contributors[j] = contributors[j], contributors[i]
}
}
}
}
// GetCommitStats gets commit statistics for the repository
func GetCommitStats(repo *git.Repository) (int, error) {
// Get HEAD reference
ref, err := repo.Head()
if err != nil {
return 0, fmt.Errorf("failed to get HEAD reference: %w", err)
}
// Get commit history
cIter, err := repo.Log(&git.LogOptions{From: ref.Hash()})
if err != nil {
return 0, fmt.Errorf("failed to get commit history: %w", err)
}
// Count commits
count := 0
err = cIter.ForEach(func(c *object.Commit) error {
count++
return nil
})
if err != nil {
return 0, fmt.Errorf("failed to process commits: %w", err)
}
return count, nil
}

272
pkg/templates/doc.html Normal file
View File

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>
<style>
/* Base styles */
:root {
--primary-color: #0366d6;
--secondary-color: #586069;
--background-color: #ffffff;
--sidebar-bg: #f6f8fa;
--border-color: #e1e4e8;
--hover-color: #f1f1f1;
--text-color: #24292e;
--sidebar-width: 260px;
--breakpoint: 768px;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.5;
color: var(--text-color);
margin: 0;
padding: 0;
display: flex;
min-height: 100vh;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border-color);
}
h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border-color);
}
pre, code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
pre {
padding: 16px;
overflow: auto;
line-height: 1.45;
background-color: var(--sidebar-bg);
border-radius: 3px;
}
code {
padding: 0.2em 0.4em;
margin: 0;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
pre code {
padding: 0;
background-color: transparent;
}
img {
max-width: 100%;
height: auto;
}
table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
table th, table td {
padding: 6px 13px;
border: 1px solid var(--border-color);
}
table tr {
background-color: var(--background-color);
border-top: 1px solid var(--border-color);
}
table tr:nth-child(2n) {
background-color: var(--sidebar-bg);
}
/* Layout */
.nav-sidebar {
width: var(--sidebar-width);
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
overflow-y: auto;
position: sticky;
top: 0;
height: 100vh;
padding: 20px;
}
.main-content {
flex: 1;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
}
/* Navigation */
.repo-info {
margin-bottom: 20px;
}
.repo-info h2 {
margin: 0;
border: none;
font-size: 1.25em;
display: flex;
align-items: center;
}
.repo-meta {
font-size: 0.9em;
color: var(--secondary-color);
}
.nav-links {
list-style-type: none;
padding: 0;
margin: 0 0 20px 0;
}
.nav-links li {
margin-bottom: 8px;
}
.nav-links a {
display: block;
padding: 6px 8px;
border-radius: 3px;
}
.nav-links a:hover {
background-color: var(--hover-color);
text-decoration: none;
}
.nav-links a.active {
font-weight: 600;
background-color: rgba(3, 102, 214, 0.1);
}
.nav-section-title {
font-weight: 600;
margin: 16px 0 8px 0;
color: var(--secondary-color);
}
.nav-footer {
margin-top: 20px;
font-size: 0.9em;
color: var(--secondary-color);
}
/* Document content */
.doc-content {
max-width: 100%;
overflow-x: auto;
}
.page-header {
margin-bottom: 30px;
}
.page-footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
color: var(--secondary-color);
font-size: 0.9em;
}
/* Mobile responsive */
@media (max-width: 768px) {
body {
flex-direction: column;
}
.nav-sidebar {
width: 100%;
height: auto;
position: relative;
border-right: none;
border-bottom: 1px solid var(--border-color);
padding: 15px;
}
.main-content {
padding: 20px;
}
}
</style>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav class="nav-sidebar">
<div class="repo-info">
<h2>
<a href="../index.html">{{.RepoFullName}}</a>
</h2>
<div class="repo-meta">
{{if .CommitCount}}📝 {{.CommitCount}} commits{{end}}
{{if .License}} • 📜 {{.License}}{{end}}
</div>
</div>
<ul class="nav-links">
<li><a href="../index.html">Repository Overview</a></li>
{{if .DocsPages}}
<div class="nav-section-title">Documentation:</div>
{{range .DocsPages}}
<li><a href="{{.Path}}" {{if .IsActive}}class="active"{{end}}>{{.Title}}</a></li>
{{end}}
{{end}}
</ul>
<div class="nav-footer">
<a href="{{.RepoURL}}" target="_blank">View on GitHub</a>
</div>
</nav>
<div class="main-content">
<header class="page-header">
<h1>{{.PageTitle}}</h1>
</header>
<main>
<div class="doc-content">
{{.PageContent}}
</div>
</main>
<footer class="page-footer">
<p>Generated on {{.GeneratedAt}} • <a href="{{.RepoURL}}" target="_blank">View on GitHub</a></p>
</footer>
</div>
</body>
</html>

381
pkg/templates/main.html Normal file
View File

@ -0,0 +1,381 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>
<style>
/* Base styles */
:root {
--primary-color: #0366d6;
--secondary-color: #586069;
--background-color: #ffffff;
--sidebar-bg: #f6f8fa;
--border-color: #e1e4e8;
--hover-color: #f1f1f1;
--text-color: #24292e;
--sidebar-width: 260px;
--breakpoint: 768px;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.5;
color: var(--text-color);
margin: 0;
padding: 0;
display: flex;
min-height: 100vh;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border-color);
}
h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--border-color);
}
pre, code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
pre {
padding: 16px;
overflow: auto;
line-height: 1.45;
background-color: var(--sidebar-bg);
border-radius: 3px;
}
code {
padding: 0.2em 0.4em;
margin: 0;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
pre code {
padding: 0;
background-color: transparent;
}
img {
max-width: 100%;
height: auto;
}
table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
table th, table td {
padding: 6px 13px;
border: 1px solid var(--border-color);
}
table tr {
background-color: var(--background-color);
border-top: 1px solid var(--border-color);
}
table tr:nth-child(2n) {
background-color: var(--sidebar-bg);
}
/* Layout */
.nav-sidebar {
width: var(--sidebar-width);
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
overflow-y: auto;
position: sticky;
top: 0;
height: 100vh;
padding: 20px;
}
.main-content {
flex: 1;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
}
/* Navigation */
.repo-info {
margin-bottom: 20px;
}
.repo-info h2 {
margin: 0;
border: none;
font-size: 1.25em;
display: flex;
align-items: center;
}
.repo-meta {
font-size: 0.9em;
color: var(--secondary-color);
}
.nav-links {
list-style-type: none;
padding: 0;
margin: 0 0 20px 0;
}
.nav-links li {
margin-bottom: 8px;
}
.nav-links a {
display: block;
padding: 6px 8px;
border-radius: 3px;
}
.nav-links a:hover {
background-color: var(--hover-color);
text-decoration: none;
}
.nav-links a.active {
font-weight: 600;
background-color: rgba(3, 102, 214, 0.1);
}
.nav-section-title {
font-weight: 600;
margin: 16px 0 8px 0;
color: var(--secondary-color);
}
.nav-footer {
margin-top: 20px;
font-size: 0.9em;
color: var(--secondary-color);
}
/* Repository sections */
.repo-header {
margin-bottom: 30px;
}
.repo-stats {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 16px;
font-size: 0.9em;
}
.repo-stat {
display: flex;
align-items: center;
gap: 5px;
}
.repo-section {
margin-bottom: 40px;
}
.contributors-list {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
}
.contributor-item {
flex: 1 1 calc(33% - 20px);
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 3px;
background-color: var(--background-color);
}
.contributor-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--primary-color);
color: white;
text-align: center;
line-height: 40px;
font-size: 18px;
}
.contributor-info {
flex: 1;
}
.contributor-name {
font-weight: 600;
}
.contributor-commits {
font-size: 0.9em;
color: var(--secondary-color);
}
.page-footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
color: var(--secondary-color);
font-size: 0.9em;
}
/* Mobile responsive */
@media (max-width: 768px) {
body {
flex-direction: column;
}
.nav-sidebar {
width: 100%;
height: auto;
position: relative;
border-right: none;
border-bottom: 1px solid var(--border-color);
padding: 15px;
}
.main-content {
padding: 20px;
}
.contributor-item {
flex: 1 1 100%;
}
}
</style>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav class="nav-sidebar">
<div class="repo-info">
<h2>
<a href="index.html">{{.RepoFullName}}</a>
</h2>
<div class="repo-meta">
{{if .CommitCount}}📝 {{.CommitCount}} commits{{end}}
{{if .License}} • 📜 {{.License}}{{end}}
</div>
</div>
<ul class="nav-links">
<li><a href="index.html" class="active">Repository Overview</a></li>
{{if .DocsPages}}
<div class="nav-section-title">Documentation:</div>
{{range .DocsPages}}
<li><a href="{{.Path}}">{{.Title}}</a></li>
{{end}}
{{end}}
</ul>
<div class="nav-footer">
<a href="{{.RepoURL}}" target="_blank">View on GitHub</a>
</div>
</nav>
<div class="main-content">
<header class="repo-header">
<h1>{{.RepoFullName}}</h1>
<div class="repo-description">{{.Description}}</div>
<div class="repo-stats">
{{if .CommitCount}}
<div class="repo-stat">
<span>📝</span> <span>{{.CommitCount}} commits</span>
</div>
{{end}}
<div class="repo-stat">
<span>📅</span> <span>Last updated: {{.LastUpdate}}</span>
</div>
{{if .License}}
<div class="repo-stat">
<span>📜</span> <span>{{.License}}</span>
</div>
{{end}}
</div>
</header>
<main>
{{if .ReadmeHTML}}
<section id="readme" class="repo-section">
<h2>README</h2>
<div class="readme-content">
{{.ReadmeHTML}}
</div>
</section>
{{end}}
{{if .Contributors}}
<section id="contributors" class="repo-section">
<h2>Top Contributors</h2>
<div class="contributors-list">
{{range .Contributors}}
<div class="contributor-item">
<!-- Use first letter as avatar if no image available -->
<div class="contributor-avatar">
{{if .Name}}{{slice .Name 0 1}}{{else}}?{{end}}
</div>
<div class="contributor-info">
<div class="contributor-name">
{{.Name}}
</div>
<div class="contributor-commits">
{{.Commits}} commits
</div>
</div>
</div>
{{end}}
</div>
<a href="{{.RepoURL}}/graphs/contributors" target="_blank">View all contributors on GitHub →</a>
</section>
{{end}}
</main>
<footer class="page-footer">
<p>Generated on {{.GeneratedAt}} • <a href="{{.RepoURL}}" target="_blank">View on GitHub</a></p>
</footer>
</div>
</body>
</html>

1
pkg/templates/style.css Normal file
View File

@ -0,0 +1 @@
/*Empty CSS file, expose for customization*/

12
pkg/templates/template.go Normal file
View File

@ -0,0 +1,12 @@
package templates
import _ "embed"
//go:embed main.html
var MainTemplate string
//go:embed doc.html
var DocTemplate string
//go:embed style.css
var StyleTemplate string

167
pkg/utils/utils.go Normal file
View File

@ -0,0 +1,167 @@
package utils
import (
"path/filepath"
"regexp"
"strings"
)
// GetOutputPath converts a markdown file path to its HTML output path
func GetOutputPath(path, baseDir string) string {
// Replace extension with .html
baseName := filepath.Base(path)
dir := filepath.Dir(path)
// Remove extension
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName)) + ".html"
// If it's in root, put it directly in baseDir
if dir == "." {
return filepath.Join(baseDir, baseName)
}
// Otherwise preserve directory structure
return filepath.Join(baseDir, dir, baseName)
}
// GetTitleFromMarkdown extracts the first heading from markdown content
func GetTitleFromMarkdown(content string) string {
// Look for the first heading
re := regexp.MustCompile(`(?m)^#\s+(.+)$`)
matches := re.FindStringSubmatch(content)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// PrettifyFilename converts a filename to a more readable title
func PrettifyFilename(filename string) string {
// Remove extension
name := strings.TrimSuffix(filename, filepath.Ext(filename))
// Replace hyphens and underscores with spaces
name = strings.ReplaceAll(name, "-", " ")
name = strings.ReplaceAll(name, "_", " ")
// Capitalize words
words := strings.Fields(name)
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[0:1]) + word[1:]
}
}
return strings.Join(words, " ")
}
// ProcessRelativeLinks handles relative links in markdown content
func ProcessRelativeLinks(content, filePath, owner, repo string) string {
baseDir := filepath.Dir(filePath)
// Replace relative links to markdown files with links to their HTML versions
re := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
return re.ReplaceAllStringFunc(content, func(match string) string {
submatch := re.FindStringSubmatch(match)
if len(submatch) < 3 {
return match
}
linkText := submatch[1]
linkTarget := submatch[2]
// Skip absolute URLs and anchors
if strings.HasPrefix(linkTarget, "http") || strings.HasPrefix(linkTarget, "#") {
return match
}
// Skip image links (we'll handle these separately)
if isImageLink(linkTarget) {
return match
}
// Handle markdown links - convert to HTML links
if isMarkdownLink(linkTarget) {
// Remove anchor if present
anchor := ""
if idx := strings.Index(linkTarget, "#"); idx > -1 {
anchor = linkTarget[idx:]
linkTarget = linkTarget[:idx]
}
// If the link is relative, resolve it
resolvedPath := linkTarget
if !strings.HasPrefix(resolvedPath, "/") {
// Handle ./file.md style links
if strings.HasPrefix(resolvedPath, "./") {
resolvedPath = resolvedPath[2:]
}
if baseDir != "." {
resolvedPath = filepath.Join(baseDir, resolvedPath)
}
} else {
// Remove leading slash
resolvedPath = resolvedPath[1:]
}
htmlPath := "../" + GetOutputPath(resolvedPath, "docs")
return "[" + linkText + "](" + htmlPath + anchor + ")"
}
return match
})
}
// GetImageLinkRegex returns a regex for matching image links in markdown
func GetImageLinkRegex() *regexp.Regexp {
return regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
}
// isImageLink checks if a link points to an image
func isImageLink(link string) bool {
extensions := []string{".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"}
lower := strings.ToLower(link)
for _, ext := range extensions {
if strings.HasSuffix(lower, ext) {
return true
}
}
return false
}
// isMarkdownLink checks if a link points to a markdown file
func isMarkdownLink(link string) bool {
extensions := []string{".md", ".markdown", ".mdown", ".mkdn"}
lower := strings.ToLower(link)
for _, ext := range extensions {
if strings.HasSuffix(lower, ext) {
return true
}
}
return false
}
// SortDocPagesByTitle sorts doc pages by title
func SortDocPagesByTitle(pages []DocPage) {
// Simple bubble sort
for i := 0; i < len(pages); i++ {
for j := i + 1; j < len(pages); j++ {
if pages[i].Title > pages[j].Title {
pages[i], pages[j] = pages[j], pages[i]
}
}
}
}
// DocPage represents a documentation page for navigation
type DocPage struct {
Title string
Path string
IsActive bool
}