package gmi2html
import (
"bytes"
_ "embed"
"fmt"
"html/template"
"net/url"
"regexp"
"strings"
)
// Based on https://geminiprotocol.net/docs/gemtext-specification.gmi
//go:embed assets/main.html
var rawTemplate string
// Gmi2html converts Gemini text to HTML with proper escaping and wraps it in a container with typography-focused CSS
func Gmi2html(text string, title string, contentOnly bool, replaceGmiExt bool) (string, error) {
content := convertGeminiContent(text, replaceGmiExt)
if contentOnly {
return content, nil
}
tmpl := template.Must(template.New("gemini").Parse(rawTemplate))
var buffer bytes.Buffer
err := tmpl.Execute(&buffer, struct {
Title string
Content template.HTML
}{
Title: title,
Content: template.HTML(content), // Content already properly escaped in convertGeminiContent
})
if err != nil {
fmt.Printf("Error executing container template: %s\n", err)
return "", err
}
return buffer.String(), nil
}
// convertGeminiContent converts Gemini text to HTML with proper escaping
func convertGeminiContent(text string, replaceGmiExt bool) string {
lines := strings.Split(text, "\n")
var buffer bytes.Buffer
normalMode := true
for _, line := range lines {
switch {
case strings.HasPrefix(line, "=>"):
handleLinkLine(&buffer, line, replaceGmiExt)
case strings.HasPrefix(line, "```"):
if normalMode {
err := preformattedTmplStart.Execute(&buffer, line)
if err != nil {
return ""
}
buffer.WriteString("\n")
} else {
err := preformattedTmplEnd.Execute(&buffer, line)
if err != nil {
return ""
}
}
normalMode = !normalMode
// Don't output the ``` line
case strings.HasPrefix(line, "###"):
content := strings.TrimSpace(strings.TrimPrefix(line, "###"))
err := h3Tmpl.Execute(&buffer, content)
if err != nil {
return ""
}
case strings.HasPrefix(line, "##"):
content := strings.TrimSpace(strings.TrimPrefix(line, "##"))
err := h2Tmpl.Execute(&buffer, content)
if err != nil {
return ""
}
case strings.HasPrefix(line, "#"):
content := strings.TrimSpace(strings.TrimPrefix(line, "#"))
err := h1Tmpl.Execute(&buffer, content)
if err != nil {
return ""
}
case strings.HasPrefix(line, "*"):
content := strings.TrimSpace(strings.TrimPrefix(line, "*"))
err := listItemTmpl.Execute(&buffer, content)
if err != nil {
return ""
}
case strings.HasPrefix(line, ">"):
content := strings.TrimSpace(strings.TrimPrefix(line, ">"))
err := blockquoteTmpl.Execute(&buffer, content)
if err != nil {
return ""
}
default:
if normalMode {
err := textLineTmpl.Execute(&buffer, line)
if err != nil {
return ""
}
} else {
buffer.WriteString(line)
buffer.WriteString("\n")
}
}
}
return buffer.String()
}
// handleLinkLine parses and renders a link line
func handleLinkLine(buffer *bytes.Buffer, linkLine string, replaceGmiExt bool) {
url, description, err := parseGeminiLink(linkLine, replaceGmiExt)
if err != nil {
fmt.Printf("Error parsing gemini link line: %s\n", err)
return
}
err = linkTmpl.Execute(buffer, struct {
URL, Description string
}{url, description})
if err != nil {
return
}
}
// parseGeminiLink extracts URL and description from a link line
func parseGeminiLink(linkLine string, replaceGmiExt bool) (string, string, error) {
re := regexp.MustCompile(`^=>[ \t]+(\S+)([ \t]+.*)?`)
matches := re.FindStringSubmatch(linkLine)
if len(matches) == 0 {
return "", "", fmt.Errorf("error parsing link line: no regexp match for line %s", linkLine)
}
urlStr := matches[1]
// Check: Unescape the URL if escaped
_, err := url.QueryUnescape(urlStr)
if err != nil {
return "", "", fmt.Errorf("error parsing link line: %w input '%s'", err, linkLine)
}
// Replace .gmi extension with .html if requested
if replaceGmiExt && strings.HasSuffix(urlStr, ".gmi") {
urlStr = strings.TrimSuffix(urlStr, ".gmi") + ".html"
}
// Set description to URL if not provided
description := urlStr
if len(matches) > 2 && strings.TrimSpace(matches[2]) != "" {
description = strings.TrimSpace(matches[2])
}
return urlStr, description, nil
}