commit 47340aebb8a405b1d72f3b98d971fbd42d619aa5 Author: antanst Date: Fri Feb 28 12:11:24 2025 +0200 Initial commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ba4b46 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +SHELL := /bin/env oksh +export PATH := $(PATH) + +all: fmt lintfix tidy test clean build + +debug: + @echo "PATH: $(PATH)" + @echo "GOPATH: $(shell go env GOPATH)" + @which go + @which gofumpt + @which gci + @which golangci-lint + +clean: + rm -rf ./dist + +# Test +test: + go test -race ./... + +tidy: + go mod tidy + +# Format code +fmt: + gofumpt -l -w . + gci write . + +# Run linter +lint: fmt + golangci-lint run + +# Run linter and fix +lintfix: fmt + golangci-lint run --fix + +build: clean + mkdir ./dist + go build -race -o ./dist/gmi2html ./bin/gmi2html/gmi2html.go + +show-updates: + go list -m -u all + +update: + go get -u all + +update-patch: + go get -u=patch all diff --git a/README.md b/README.md new file mode 100644 index 0000000..89b9cbd --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# gmi2html + +A small library (and CLI tool) that converts Gemini text to HTML. + +To run tests and build: + +```shell +make +``` + +Running: + +```shell +./dist/gmi2html gemtext.html +``` \ No newline at end of file diff --git a/bin/gmi2html/gmi2html.go b/bin/gmi2html/gmi2html.go new file mode 100644 index 0000000..d253967 --- /dev/null +++ b/bin/gmi2html/gmi2html.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "io" + "os" + + "github.com/antanst/gmi2html" +) + +func main() { + err := runApp() + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func runApp() error { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + html := gmi2html.Gmi2html(string(data), "") + _, err = fmt.Fprintf(os.Stdout, "%s", html) + if err != nil { + return err + } + return nil +} diff --git a/gmi2html.go b/gmi2html.go new file mode 100644 index 0000000..1e2de68 --- /dev/null +++ b/gmi2html.go @@ -0,0 +1,135 @@ +package gmi2html + +import ( + "bytes" + "fmt" + "html/template" + "net/url" + "regexp" + "strings" +) + +// Based on https://geminiprotocol.net/docs/gemtext-specification.gmi + +// 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) string { + content := convertGeminiContent(text) + + // Handle any template errors with container + var buffer bytes.Buffer + err := containerTmpl.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 "" + } + + return buffer.String() +} + +// convertGeminiContent converts Gemini text to HTML with proper escaping +func convertGeminiContent(text string) string { + lines := strings.Split(text, "\n") + var buffer bytes.Buffer + normalMode := true + + for _, line := range lines { + switch { + case strings.HasPrefix(line, "=>"): + handleLinkLine(&buffer, line) + case strings.HasPrefix(line, "```"): + 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 { + err := preformattedTmpl.Execute(&buffer, line) + if err != nil { + return "" + } + } + } + } + + return buffer.String() +} + +// handleLinkLine parses and renders a link line +func handleLinkLine(buffer *bytes.Buffer, linkLine string) { + url, description, err := parseGeminiLink(linkLine) + 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) (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) + } + + // 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 +} diff --git a/gmi2html_test.go b/gmi2html_test.go new file mode 100644 index 0000000..bb82ffc --- /dev/null +++ b/gmi2html_test.go @@ -0,0 +1,159 @@ +package gmi2html + +import ( + "strings" + "testing" +) + +func TestConvertGeminiContent(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Simple text line", + input: "This is a simple text line", + expected: `

This is a simple text line

`, + }, + { + name: "Heading line level 1", + input: "# Main heading", + expected: `

Main heading

`, + }, + { + name: "Link line with description", + input: "=> https://example.com Example site", + expected: ``, + }, + { + name: "List item", + input: "* List item 1", + expected: `

• List item 1

`, + }, + { + name: "Quote line", + input: "> This is a quote", + expected: `
This is a quote
`, + }, + { + name: "Preformatted text", + input: "```\ncode line 1\ncode line 2\n```", + expected: `
code line 1
code line 2
`, + }, + { + name: "Mixed content", + input: "# Title\n\nNormal paragraph\n\n=> https://example.com Link to example", + expected: `

Title

Normal paragraph

`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertGeminiContent(tt.input) + if result != tt.expected { + t.Errorf("convertGeminiContent(%q):\ngot: %s\nwant: %s", + tt.input, result, tt.expected) + } + }) + } +} + +func TestGmi2html(t *testing.T) { + sample := "# Hello Gemini\n\nThis is a test document.\n\n=> https://gemini.circumlunar.space/ Project Gemini" + result := Gmi2html(sample, "Gemini Test") + + // Check that it contains the expected elements + if !strings.Contains(result, "Gemini Test") { + t.Error("Output HTML missing title") + } + + if !strings.Contains(result, "

Hello Gemini

") { + t.Error("Output HTML missing properly formatted heading") + } + + if !strings.Contains(result, "Project Gemini") { + t.Error("Output HTML missing properly formatted link") + } + + // Check that CSS is included + if !strings.Contains(result, " + + +
+ {{.Content}} +
+ +`)) +)