diff --git a/README.md b/README.md index 89b9cbd..c60ba84 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,24 @@ Running: ```shell ./dist/gmi2html gemtext.html -``` \ No newline at end of file +``` + +Options: + +- `--no-container`: Don't output container HTML +- `--replace-gmi-ext`: Replace .gmi extension with .html in links + +Example: + +```shell +# Convert Gemini text and replace all .gmi links with .html +./dist/gmi2html --replace-gmi-ext output.html + +# Convert only the content without wrapping it in the HTML container +./dist/gmi2html --no-container output-content.html +``` + +Help: +```shell +./dist/gmi2html --help +``` diff --git a/cmd/gmi2html/main.go b/cmd/gmi2html/main.go new file mode 100644 index 0000000..c26469d --- /dev/null +++ b/cmd/gmi2html/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/antanst/gmi2html/pkg/gmi2html" +) + +func main() { + noContainer := flag.Bool("no-container", false, "Don't output container HTML") + replaceGmiExt := flag.Bool("replace-gmi-ext", false, "Replace .gmi extension with .html in links") + flag.Parse() + err := runApp(*noContainer, *replaceGmiExt) + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func runApp(noContainer bool, replaceGmiExt bool) error { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + html, err := gmi2html.Gmi2html(string(data), "", noContainer, replaceGmiExt) + if err != nil { + return err + } + _, err = fmt.Fprintf(os.Stdout, "%s", html) + if err != nil { + return err + } + return nil +} diff --git a/pkg/gmi2html/assets/main.html b/pkg/gmi2html/assets/main.html new file mode 100644 index 0000000..44c1578 --- /dev/null +++ b/pkg/gmi2html/assets/main.html @@ -0,0 +1,126 @@ + + + + + + {{.Title}} + + + +
+ {{.Content}} +
+ + \ No newline at end of file diff --git a/pkg/gmi2html/gmi2html.go b/pkg/gmi2html/gmi2html.go new file mode 100644 index 0000000..3db14c9 --- /dev/null +++ b/pkg/gmi2html/gmi2html.go @@ -0,0 +1,159 @@ +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 +} diff --git a/pkg/gmi2html/gmi2html_test.go b/pkg/gmi2html/gmi2html_test.go new file mode 100644 index 0000000..8274a3f --- /dev/null +++ b/pkg/gmi2html/gmi2html_test.go @@ -0,0 +1,179 @@ +package gmi2html + +import ( + "testing" +) + +func TestGmi2html(t *testing.T) { + tests := []struct { + name string + input string + title string + contentOnly bool + replaceGmiExt bool + want string + wantErr bool + }{ + { + name: "Basic text", + input: "Hello world", + title: "Test", + contentOnly: true, + replaceGmiExt: false, + want: "

Hello world

", + wantErr: false, + }, + { + name: "Headers", + input: "# Header 1\n## Header 2\n### Header 3", + title: "Test", + contentOnly: true, + replaceGmiExt: false, + want: "

Header 1

Header 2

Header 3

", + wantErr: false, + }, + { + name: "List items", + input: "* Item 1\n* Item 2", + title: "Test", + contentOnly: true, + replaceGmiExt: false, + want: "

• Item 1

• Item 2

", + wantErr: false, + }, + { + name: "Blockquote", + input: "> This is a quote", + title: "Test", + contentOnly: true, + replaceGmiExt: false, + want: "
This is a quote
", + wantErr: false, + }, + { + name: "Link", + input: "=> https://example.com Example Link", + title: "Test", + contentOnly: true, + replaceGmiExt: false, + want: "", + wantErr: false, + }, + { + name: "Link with gmi extension replacement", + input: "=> /path/file.gmi Example Link", + title: "Test", + contentOnly: true, + replaceGmiExt: true, + want: "", + wantErr: false, + }, + { + name: "Preformatted text", + input: "```\nThis is preformatted\n```", + title: "Test", + contentOnly: true, + replaceGmiExt: false, + want: "
\nThis is preformatted\n
", + wantErr: false, + }, + { + name: "Mixed content", + input: "# Title\nNormal text\n=> https://example.com Link\n```\nCode\n```\n* List item", + title: "Test", + contentOnly: true, + replaceGmiExt: false, + want: "

Title

Normal text

\nCode\n

• List item

", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Gmi2html(tt.input, tt.title, tt.contentOnly, tt.replaceGmiExt) + if (err != nil) != tt.wantErr { + t.Errorf("Gmi2html() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Gmi2html() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseGeminiLink(t *testing.T) { + tests := []struct { + name string + linkLine string + replaceGmiExt bool + wantURL string + wantDesc string + wantErr bool + }{ + { + name: "Basic link", + linkLine: "=> https://example.com Example Link", + replaceGmiExt: false, + wantURL: "https://example.com", + wantDesc: "Example Link", + wantErr: false, + }, + { + name: "Link without description", + linkLine: "=> https://example.com", + replaceGmiExt: false, + wantURL: "https://example.com", + wantDesc: "https://example.com", + wantErr: false, + }, + { + name: "Invalid link format", + linkLine: "Invalid line", + replaceGmiExt: false, + wantURL: "", + wantDesc: "", + wantErr: true, + }, + { + name: "Link with .gmi extension, no replacement", + linkLine: "=> /path/file.gmi Link to Gemini file", + replaceGmiExt: false, + wantURL: "/path/file.gmi", + wantDesc: "Link to Gemini file", + wantErr: false, + }, + { + name: "Link with .gmi extension, with replacement", + linkLine: "=> /path/file.gmi Link to Gemini file", + replaceGmiExt: true, + wantURL: "/path/file.html", + wantDesc: "Link to Gemini file", + wantErr: false, + }, + { + name: "Link without .gmi extension, with replacement", + linkLine: "=> /path/file.txt Link to text file", + replaceGmiExt: true, + wantURL: "/path/file.txt", + wantDesc: "Link to text file", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotURL, gotDesc, err := parseGeminiLink(tt.linkLine, tt.replaceGmiExt) + if (err != nil) != tt.wantErr { + t.Errorf("parseGeminiLink() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotURL != tt.wantURL { + t.Errorf("parseGeminiLink() gotURL = %v, want %v", gotURL, tt.wantURL) + } + if gotDesc != tt.wantDesc { + t.Errorf("parseGeminiLink() gotDesc = %v, want %v", gotDesc, tt.wantDesc) + } + }) + } +} diff --git a/pkg/gmi2html/templates.go b/pkg/gmi2html/templates.go new file mode 100644 index 0000000..2f61519 --- /dev/null +++ b/pkg/gmi2html/templates.go @@ -0,0 +1,17 @@ +package gmi2html + +import "html/template" + +// Templates for different line types + +var ( + textLineTmpl = template.Must(template.New("textLine").Parse(`

{{.}}

`)) + h1Tmpl = template.Must(template.New("h1").Parse(`

{{.}}

`)) + h2Tmpl = template.Must(template.New("h2").Parse(`

{{.}}

`)) + h3Tmpl = template.Must(template.New("h3").Parse(`

{{.}}

`)) + listItemTmpl = template.Must(template.New("listItem").Parse(`

• {{.}}

`)) + blockquoteTmpl = template.Must(template.New("blockquote").Parse(`
{{.}}
`)) + preformattedTmplStart = template.Must(template.New("preformatted").Parse(`
`))
+	preformattedTmplEnd   = template.Must(template.New("preformatted").Parse(`
`)) + linkTmpl = template.Must(template.New("link").Parse(``)) +)