Compare commits
7 Commits
25c39036d3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139b2e7733 | ||
|
|
971f1e5206 | ||
|
|
39651a6021 | ||
| 82a2083422 | |||
| af3bdd7d9a | |||
| d3c36c9e74 | |||
| 8126ded0a6 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/.idea
|
||||
/.vscode
|
||||
/dist
|
||||
44
AGENTS.md
Normal file
44
AGENTS.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
Build, test, and develop:
|
||||
```shell
|
||||
make # Full workflow: format, lint, test, clean, build
|
||||
make test # Run tests only
|
||||
make build # Build binary to ./dist/gmi2html
|
||||
make fmt # Format code with gofumpt and gci
|
||||
make lint # Run linter after formatting
|
||||
make lintfix # Run linter with auto-fix
|
||||
```
|
||||
|
||||
Running the tool:
|
||||
```shell
|
||||
./dist/gmi2html <input.gmi >output.html
|
||||
./dist/gmi2html --no-container <input.gmi >content.html
|
||||
./dist/gmi2html --replace-gmi-ext <input.gmi >output.html
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Go library and CLI tool that converts Gemini text format to HTML.
|
||||
|
||||
**Core Components:**
|
||||
- `gmi2html.go`: Main conversion logic with `Gmi2html()` function and `convertGeminiContent()` for parsing
|
||||
- `templates.go`: HTML templates for each Gemini element type (headings, links, lists, etc.)
|
||||
- `cmd/gmi2html/main.go`: CLI entry point that reads from stdin and writes to stdout
|
||||
- `assets/main.html`: Embedded HTML template with CSS for the full page container
|
||||
|
||||
**Key Architecture Patterns:**
|
||||
- Uses Go's `html/template` package for safe HTML generation with automatic escaping
|
||||
- Embeds the main HTML template using `//go:embed` directive
|
||||
- Line-by-line parser that switches between normal and preformatted modes
|
||||
- Two output modes: full HTML document or content-only for embedding
|
||||
- Optional `.gmi` to `.html` link conversion for static site generation
|
||||
|
||||
**Gemini Format Support:**
|
||||
- Headings (#, ##, ###), links (=>), lists (*), quotes (>), preformatted blocks (```)
|
||||
- Proper handling of preformatted content with mode switching
|
||||
- URL parsing and validation for links
|
||||
98
CLAUDE.md
Normal file
98
CLAUDE.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to AI Agents such as Claude Code or ChatGPT Codex when working with code in this repository.
|
||||
|
||||
## General guidelines
|
||||
|
||||
Use idiomatic Go as possible. Prefer simple code than complex.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Gemserve is a simple Gemini protocol server written in Go that serves static files over TLS-encrypted connections. The Gemini protocol is a lightweight, privacy-focused alternative to HTTP designed for serving text-based content.
|
||||
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# Build, test, and format everything
|
||||
make
|
||||
|
||||
# Run tests only
|
||||
make test
|
||||
|
||||
# Build binaries to ./dist/ (gemserve, gemget, gembench)
|
||||
make build
|
||||
|
||||
# Format code with gofumpt and gci
|
||||
make fmt
|
||||
|
||||
# Run golangci-lint
|
||||
make lint
|
||||
|
||||
# Run linter with auto-fix
|
||||
make lintfix
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
|
||||
# Run the server (after building)
|
||||
./dist/gemserve
|
||||
|
||||
# Generate TLS certificates for development
|
||||
certs/generate.sh
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
Core Components
|
||||
|
||||
- **cmd/gemserve/gemserve.go**: Entry point with TLS server setup, signal handling, and graceful shutdown
|
||||
- **cmd/gemget/**: Gemini protocol client for fetching content
|
||||
- **cmd/gembench/**: Benchmarking tool for Gemini servers
|
||||
- **server/**: Request processing, file serving, and Gemini protocol response handling
|
||||
- **gemini/**: Gemini protocol implementation (URL parsing, status codes, path normalization)
|
||||
- **config/**: CLI-based configuration system
|
||||
- **lib/logging/**: Structured logging package with context-aware loggers
|
||||
- **lib/apperrors/**: Application error handling (fatal vs non-fatal errors)
|
||||
- **uid/**: Connection ID generation for logging (uses external vendor package)
|
||||
|
||||
Key Patterns
|
||||
|
||||
- **Security First**: All file operations use `filepath.IsLocal()` and path cleaning to prevent directory traversal
|
||||
- **Error Handling**: Uses structured errors via `lib/apperrors` package distinguishing fatal from non-fatal errors
|
||||
- **Logging**: Structured logging with configurable levels via internal logging package
|
||||
- **Testing**: Table-driven tests with parallel execution, heavy focus on security edge cases
|
||||
|
||||
Request Flow
|
||||
|
||||
1. TLS connection established on port 1965
|
||||
2. Read up to 1KB request (Gemini spec limit)
|
||||
3. Parse and normalize Gemini URL
|
||||
4. Validate path security (prevent traversal)
|
||||
5. Serve file or directory index with appropriate MIME type
|
||||
6. Send response with proper Gemini status codes
|
||||
|
||||
Configuration
|
||||
|
||||
Server configured via CLI flags:
|
||||
- `--listen`: Server address (default: localhost:1965)
|
||||
- `--root-path`: Directory to serve files from
|
||||
- `--dir-indexing`: Enable directory browsing (default: false)
|
||||
- `--log-level`: Logging verbosity (debug, info, warn, error; default: info)
|
||||
- `--response-timeout`: Response timeout in seconds (default: 30)
|
||||
- `--tls-cert`: TLS certificate file path (default: certs/server.crt)
|
||||
- `--tls-key`: TLS key file path (default: certs/server.key)
|
||||
- `--max-response-size`: Maximum response size in bytes (default: 5242880)
|
||||
|
||||
Testing Strategy
|
||||
|
||||
- **server/server_test.go**: Path security and file serving tests
|
||||
- **gemini/url_test.go**: URL parsing and normalization tests
|
||||
- Focus on security edge cases (Unicode, traversal attempts, malformed URLs)
|
||||
- Use parallel test execution for performance
|
||||
|
||||
Security Considerations
|
||||
|
||||
- All connections require TLS certificates (stored in certs/)
|
||||
- Path traversal protection is critical - test thoroughly when modifying file serving logic
|
||||
- Request size limited to 1KB per Gemini specification
|
||||
- Input validation on all URLs and paths
|
||||
8
Makefile
8
Makefile
@@ -1,4 +1,4 @@
|
||||
SHELL := /bin/env oksh
|
||||
SHELL := /bin/sh
|
||||
export PATH := $(PATH)
|
||||
|
||||
all: fmt lintfix tidy test clean build
|
||||
@@ -8,7 +8,6 @@ debug:
|
||||
@echo "GOPATH: $(shell go env GOPATH)"
|
||||
@which go
|
||||
@which gofumpt
|
||||
@which gci
|
||||
@which golangci-lint
|
||||
|
||||
clean:
|
||||
@@ -24,7 +23,6 @@ tidy:
|
||||
# Format code
|
||||
fmt:
|
||||
gofumpt -l -w .
|
||||
gci write .
|
||||
|
||||
# Run linter
|
||||
lint: fmt
|
||||
@@ -36,10 +34,10 @@ lintfix: fmt
|
||||
|
||||
build: clean
|
||||
mkdir ./dist
|
||||
go build -o ./dist/gmi2html ./bin/gmi2html/gmi2html.go
|
||||
go build -o ./dist/gmi2html ./cmd/gmi2html
|
||||
|
||||
build-gccgo: clean
|
||||
go build -compiler=gccgo -o ./dist/gmi2html ./bin/gmi2html/gmi2html.go
|
||||
go build -compiler=gccgo -o ./dist/gmi2html ./cmd/gmi2html
|
||||
|
||||
show-updates:
|
||||
go list -m -u all
|
||||
|
||||
22
README.md
22
README.md
@@ -12,4 +12,24 @@ Running:
|
||||
|
||||
```shell
|
||||
./dist/gmi2html <gemtext.gmi >gemtext.html
|
||||
```
|
||||
```
|
||||
|
||||
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 <input.gmi >output.html
|
||||
|
||||
# Convert only the content without wrapping it in the HTML container
|
||||
./dist/gmi2html --no-container <input.gmi >output-content.html
|
||||
```
|
||||
|
||||
Help:
|
||||
```shell
|
||||
./dist/gmi2html --help
|
||||
```
|
||||
|
||||
130
assets/main.html
Normal file
130
assets/main.html
Normal file
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--text-color: #333;
|
||||
--bg-color: #ffffff;
|
||||
--link-color: #0066cc;
|
||||
--link-hover: #0052a3;
|
||||
--quote-bg: #f5f5f5;
|
||||
--quote-border: #ddd;
|
||||
--pre-bg: #f8f8f8;
|
||||
--pre-border: #e1e1e1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text-color: #e0e0e0;
|
||||
--bg-color: #1a1a1a;
|
||||
--link-color: #66a3ff;
|
||||
--link-hover: #87b5ff;
|
||||
--quote-bg: #2a2a2a;
|
||||
--quote-border: #444;
|
||||
--pre-bg: #252525;
|
||||
--pre-border: #3a3a3a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: serif;
|
||||
/* font-weight: 300; */
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
max-width: 34rem;
|
||||
amargin: 0 auto;
|
||||
amargin-left: 2rem;
|
||||
padding: 1rem 1rem;
|
||||
atext-align: justify;
|
||||
ahyphens: auto;
|
||||
a-webkit-hyphens: auto;
|
||||
a-ms-hyphens: auto;
|
||||
}
|
||||
|
||||
.gemini-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gemini-heading-1 {
|
||||
font-size: 2rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gemini-heading-2 {
|
||||
font-size: 1.6rem;
|
||||
margin-top: 0.8rem;
|
||||
margin-bottom: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gemini-heading-3 {
|
||||
font-size: 1.3rem;
|
||||
margin-top: 0.7rem;
|
||||
margin-bottom: 0.7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gemini-textline {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gemini-list-item {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.gemini-link-container {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.gemini-link-container a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.gemini-link-container a:hover {
|
||||
color: var(--link-hover);
|
||||
border-bottom-color: var(--link-hover);
|
||||
}
|
||||
|
||||
.gemini-blockquote {
|
||||
background-color: var(--quote-bg);
|
||||
border-left: 3px solid var(--quote-border);
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.8rem 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.gemini-preformatted {
|
||||
background-color: var(--pre-bg);
|
||||
border: 1px solid var(--pre-border);
|
||||
border-radius: 3px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
margin: 1rem 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="gemini-container">
|
||||
{{.Content}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,34 +0,0 @@
|
||||
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, err := gmi2html.Gmi2html(string(data), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(os.Stdout, "%s", html)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
38
cmd/gmi2html/main.go
Normal file
38
cmd/gmi2html/main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.antanst.com/antanst/gmi2html"
|
||||
)
|
||||
|
||||
func main() {
|
||||
noContainer := flag.Bool("no-container", false, "Don't output container HTML")
|
||||
replaceGmiExt := flag.Bool("replace-gmi-ext", false, "In links, replace original .gmi extension with .html")
|
||||
flag.Parse()
|
||||
err := runApp(*noContainer, *replaceGmiExt)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, 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
|
||||
}
|
||||
76
gmi2html.go
76
gmi2html.go
@@ -2,6 +2,7 @@ package gmi2html
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
@@ -11,13 +12,21 @@ import (
|
||||
|
||||
// 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, error) {
|
||||
content := convertGeminiContent(text)
|
||||
//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))
|
||||
|
||||
// Handle any template errors with container
|
||||
var buffer bytes.Buffer
|
||||
err := containerTmpl.Execute(&buffer, struct {
|
||||
err := tmpl.Execute(&buffer, struct {
|
||||
Title string
|
||||
Content template.HTML
|
||||
}{
|
||||
@@ -33,18 +42,41 @@ func Gmi2html(text string, title string) (string, error) {
|
||||
}
|
||||
|
||||
// convertGeminiContent converts Gemini text to HTML with proper escaping
|
||||
func convertGeminiContent(text string) string {
|
||||
func convertGeminiContent(text string, replaceGmiExt bool) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
var buffer bytes.Buffer
|
||||
normalMode := true
|
||||
|
||||
for _, line := range lines {
|
||||
if 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 itself
|
||||
continue
|
||||
}
|
||||
|
||||
if !normalMode {
|
||||
// Inside preformatted block - output line directly with HTML escaping
|
||||
buffer.WriteString(template.HTMLEscapeString(line))
|
||||
buffer.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// Normal mode - process gemini markup
|
||||
switch {
|
||||
case strings.HasPrefix(line, "=>"):
|
||||
handleLinkLine(&buffer, line)
|
||||
case strings.HasPrefix(line, "```"):
|
||||
normalMode = !normalMode
|
||||
// Don't output the ``` line
|
||||
handleLinkLine(&buffer, line, replaceGmiExt)
|
||||
case strings.HasPrefix(line, "###"):
|
||||
content := strings.TrimSpace(strings.TrimPrefix(line, "###"))
|
||||
err := h3Tmpl.Execute(&buffer, content)
|
||||
@@ -76,16 +108,9 @@ func convertGeminiContent(text string) string {
|
||||
return ""
|
||||
}
|
||||
default:
|
||||
if normalMode {
|
||||
err := textLineTmpl.Execute(&buffer, line)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
} else {
|
||||
err := preformattedTmpl.Execute(&buffer, line)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
err := textLineTmpl.Execute(&buffer, line)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,8 +119,8 @@ func convertGeminiContent(text string) string {
|
||||
}
|
||||
|
||||
// handleLinkLine parses and renders a link line
|
||||
func handleLinkLine(buffer *bytes.Buffer, linkLine string) {
|
||||
url, description, err := parseGeminiLink(linkLine)
|
||||
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
|
||||
@@ -110,7 +135,7 @@ func handleLinkLine(buffer *bytes.Buffer, linkLine string) {
|
||||
}
|
||||
|
||||
// parseGeminiLink extracts URL and description from a link line
|
||||
func parseGeminiLink(linkLine string) (string, string, error) {
|
||||
func parseGeminiLink(linkLine string, replaceGmiExt bool) (string, string, error) {
|
||||
re := regexp.MustCompile(`^=>[ \t]+(\S+)([ \t]+.*)?`)
|
||||
matches := re.FindStringSubmatch(linkLine)
|
||||
if len(matches) == 0 {
|
||||
@@ -125,6 +150,11 @@ func parseGeminiLink(linkLine string) (string, string, error) {
|
||||
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]) != "" {
|
||||
|
||||
232
gmi2html_test.go
232
gmi2html_test.go
@@ -1,158 +1,178 @@
|
||||
package gmi2html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertGeminiContent(t *testing.T) {
|
||||
func TestGmi2html(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
name string
|
||||
input string
|
||||
title string
|
||||
contentOnly bool
|
||||
replaceGmiExt bool
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Simple text line",
|
||||
input: "This is a simple text line",
|
||||
expected: `<p class="gemini-textline">This is a simple text line</p>`,
|
||||
name: "Basic text",
|
||||
input: "Hello world",
|
||||
title: "Test",
|
||||
contentOnly: true,
|
||||
replaceGmiExt: false,
|
||||
want: "<p class=\"gemini-textline\">Hello world</p>",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Heading line level 1",
|
||||
input: "# Main heading",
|
||||
expected: `<h1 class="gemini-heading-1">Main heading</h1>`,
|
||||
name: "Headers",
|
||||
input: "# Header 1\n## Header 2\n### Header 3",
|
||||
title: "Test",
|
||||
contentOnly: true,
|
||||
replaceGmiExt: false,
|
||||
want: "<h1 class=\"gemini-heading-1\">Header 1</h1><h2 class=\"gemini-heading-2\">Header 2</h2><h3 class=\"gemini-heading-3\">Header 3</h3>",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Link line with description",
|
||||
input: "=> https://example.com Example site",
|
||||
expected: `<div class="gemini-link-container"><a href="https://example.com">Example site</a></div>`,
|
||||
name: "List items",
|
||||
input: "* Item 1\n* Item 2",
|
||||
title: "Test",
|
||||
contentOnly: true,
|
||||
replaceGmiExt: false,
|
||||
want: "<p class=\"gemini-list-item\">• Item 1</p><p class=\"gemini-list-item\">• Item 2</p>",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "List item",
|
||||
input: "* List item 1",
|
||||
expected: `<p class="gemini-list-item">• List item 1</p>`,
|
||||
name: "Blockquote",
|
||||
input: "> This is a quote",
|
||||
title: "Test",
|
||||
contentOnly: true,
|
||||
replaceGmiExt: false,
|
||||
want: "<blockquote class=\"gemini-blockquote\">This is a quote</blockquote>",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Quote line",
|
||||
input: "> This is a quote",
|
||||
expected: `<blockquote class="gemini-blockquote">This is a quote</blockquote>`,
|
||||
name: "Link",
|
||||
input: "=> https://example.com Example Link",
|
||||
title: "Test",
|
||||
contentOnly: true,
|
||||
replaceGmiExt: false,
|
||||
want: "<div class=\"gemini-link-container\"><a href=\"https://example.com\">Example Link</a></div>",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Preformatted text",
|
||||
input: "```\ncode line 1\ncode line 2\n```",
|
||||
expected: `<pre class="gemini-preformatted">code line 1</pre><pre class="gemini-preformatted">code line 2</pre>`,
|
||||
name: "Link with gmi extension replacement",
|
||||
input: "=> /path/file.gmi Example Link",
|
||||
title: "Test",
|
||||
contentOnly: true,
|
||||
replaceGmiExt: true,
|
||||
want: "<div class=\"gemini-link-container\"><a href=\"/path/file.html\">Example Link</a></div>",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Mixed content",
|
||||
input: "# Title\n\nNormal paragraph\n\n=> https://example.com Link to example",
|
||||
expected: `<h1 class="gemini-heading-1">Title</h1><p class="gemini-textline"></p><p class="gemini-textline">Normal paragraph</p><p class="gemini-textline"></p><div class="gemini-link-container"><a href="https://example.com">Link to example</a></div>`,
|
||||
name: "Preformatted text",
|
||||
input: "```\nThis is preformatted\n```",
|
||||
title: "Test",
|
||||
contentOnly: true,
|
||||
replaceGmiExt: false,
|
||||
want: "<pre class=\"gemini-preformatted\">\nThis is preformatted\n</pre>",
|
||||
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: "<h1 class=\"gemini-heading-1\">Title</h1><p class=\"gemini-textline\">Normal text</p><div class=\"gemini-link-container\"><a href=\"https://example.com\">Link</a></div><pre class=\"gemini-preformatted\">\nCode\n</pre><p class=\"gemini-list-item\">• List item</p>",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
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 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, "<title>Gemini Test</title>") {
|
||||
t.Error("Output HTML missing title")
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "<h1 class=\"gemini-heading-1\">Hello Gemini</h1>") {
|
||||
t.Error("Output HTML missing properly formatted heading")
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "<a href=\"https://gemini.circumlunar.space/\">Project Gemini</a>") {
|
||||
t.Error("Output HTML missing properly formatted link")
|
||||
}
|
||||
|
||||
// Check that CSS is included
|
||||
if !strings.Contains(result, "<style>") {
|
||||
t.Error("Output HTML missing style section")
|
||||
}
|
||||
|
||||
// Check that basic HTML structure is there
|
||||
if !strings.Contains(result, "<!DOCTYPE html>") {
|
||||
t.Error("Output HTML missing doctype declaration")
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "<div class=\"gemini-container\">") {
|
||||
t.Error("Output HTML missing container div")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGeminiLink(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedURL string
|
||||
expectedDesc string
|
||||
expectError bool
|
||||
name string
|
||||
linkLine string
|
||||
replaceGmiExt bool
|
||||
wantURL string
|
||||
wantDesc string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid link with description",
|
||||
input: "=> https://example.com Example site",
|
||||
expectedURL: "https://example.com",
|
||||
expectedDesc: "Example site",
|
||||
expectError: false,
|
||||
name: "Basic link",
|
||||
linkLine: "=> https://example.com Example Link",
|
||||
replaceGmiExt: false,
|
||||
wantURL: "https://example.com",
|
||||
wantDesc: "Example Link",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid link without description",
|
||||
input: "=> https://example.com",
|
||||
expectedURL: "https://example.com",
|
||||
expectedDesc: "https://example.com",
|
||||
expectError: false,
|
||||
name: "Link without description",
|
||||
linkLine: "=> https://example.com",
|
||||
replaceGmiExt: false,
|
||||
wantURL: "https://example.com",
|
||||
wantDesc: "https://example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Link with special characters",
|
||||
input: "=> https://example.com/search?q=test Test search",
|
||||
expectedURL: "https://example.com/search?q=test",
|
||||
expectedDesc: "Test search",
|
||||
expectError: false,
|
||||
name: "Invalid link format",
|
||||
linkLine: "Invalid line",
|
||||
replaceGmiExt: false,
|
||||
wantURL: "",
|
||||
wantDesc: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed link line",
|
||||
input: "=>",
|
||||
expectedURL: "",
|
||||
expectedDesc: "",
|
||||
expectError: 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 multiple spaces in description",
|
||||
input: "=> https://example.com Multiple spaces",
|
||||
expectedURL: "https://example.com",
|
||||
expectedDesc: "Multiple spaces",
|
||||
expectError: 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) {
|
||||
url, desc, err := parseGeminiLink(tt.input)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("Expected error: %v, got error: %v", tt.expectError, err != nil)
|
||||
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 we expect success, check the result
|
||||
if !tt.expectError {
|
||||
if url != tt.expectedURL {
|
||||
t.Errorf("Expected URL: %s, got: %s", tt.expectedURL, url)
|
||||
}
|
||||
if desc != tt.expectedDesc {
|
||||
t.Errorf("Expected description: %s, got: %s", tt.expectedDesc, desc)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,3 +1,3 @@
|
||||
module github.com/antanst/gmi2html
|
||||
module git.antanst.com/antanst/gmi2html
|
||||
|
||||
go 1.23.6
|
||||
go 1.21
|
||||
|
||||
145
templates.go
145
templates.go
@@ -5,140 +5,13 @@ import "html/template"
|
||||
// Templates for different line types
|
||||
|
||||
var (
|
||||
textLineTmpl = template.Must(template.New("textLine").Parse(`<p class="gemini-textline">{{.}}</p>`))
|
||||
h1Tmpl = template.Must(template.New("h1").Parse(`<h1 class="gemini-heading-1">{{.}}</h1>`))
|
||||
h2Tmpl = template.Must(template.New("h2").Parse(`<h2 class="gemini-heading-2">{{.}}</h2>`))
|
||||
h3Tmpl = template.Must(template.New("h3").Parse(`<h3 class="gemini-heading-3">{{.}}</h3>`))
|
||||
listItemTmpl = template.Must(template.New("listItem").Parse(`<p class="gemini-list-item">• {{.}}</p>`))
|
||||
blockquoteTmpl = template.Must(template.New("blockquote").Parse(`<blockquote class="gemini-blockquote">{{.}}</blockquote>`))
|
||||
preformattedTmpl = template.Must(template.New("preformatted").Parse(`<pre class="gemini-preformatted">{{.}}</pre>`))
|
||||
linkTmpl = template.Must(template.New("link").Parse(`<div class="gemini-link-container"><a href="{{.URL}}">{{.Description}}</a></div>`))
|
||||
|
||||
// Container template with typography-focused CSS
|
||||
containerTmpl = template.Must(template.New("container").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--text-color: #333;
|
||||
--bg-color: #fff;
|
||||
--link-color: #0066cc;
|
||||
--link-hover: #004080;
|
||||
--quote-bg: #f5f5f5;
|
||||
--quote-border: #ddd;
|
||||
--pre-bg: #f8f8f8;
|
||||
--pre-border: #eaeaea;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text-color: #eee;
|
||||
--bg-color: #292929;
|
||||
--link-color: #4a9eff;
|
||||
--link-hover: #77b6ff;
|
||||
--quote-bg: #333;
|
||||
--quote-border: #444;
|
||||
--pre-bg: #2a2a2a;
|
||||
--pre-border: #3a3a3a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Source Serif Pro", "Georgia", "Cambria", serif;
|
||||
color: var(--text-color);
|
||||
/* background-color: var(--bg-color); */
|
||||
max-width: 34rem;
|
||||
/* margin: 0 auto; */
|
||||
margin-left: 2rem;
|
||||
padding: 1rem 1rem;
|
||||
font-size: 16px;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
-ms-hyphens: auto;
|
||||
}
|
||||
|
||||
.gemini-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gemini-heading-1 {
|
||||
font-size: 2rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gemini-heading-2 {
|
||||
font-size: 1.6rem;
|
||||
margin-top: 0.8rem;
|
||||
margin-bottom: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gemini-heading-3 {
|
||||
font-size: 1.3rem;
|
||||
margin-top: 0.7rem;
|
||||
margin-bottom: 0.7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gemini-textline {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gemini-list-item {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.gemini-link-container {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.gemini-link-container a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.gemini-link-container a:hover {
|
||||
color: var(--link-hover);
|
||||
border-bottom-color: var(--link-hover);
|
||||
}
|
||||
|
||||
.gemini-blockquote {
|
||||
background-color: var(--quote-bg);
|
||||
border-left: 3px solid var(--quote-border);
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.8rem 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.gemini-preformatted {
|
||||
background-color: var(--pre-bg);
|
||||
border: 1px solid var(--pre-border);
|
||||
border-radius: 3px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
margin: 1rem 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="gemini-container">
|
||||
{{.Content}}
|
||||
</div>
|
||||
</body>
|
||||
</html>`))
|
||||
textLineTmpl = template.Must(template.New("textLine").Parse(`<p class="gemini-textline">{{.}}</p>`))
|
||||
h1Tmpl = template.Must(template.New("h1").Parse(`<h1 class="gemini-heading-1">{{.}}</h1>`))
|
||||
h2Tmpl = template.Must(template.New("h2").Parse(`<h2 class="gemini-heading-2">{{.}}</h2>`))
|
||||
h3Tmpl = template.Must(template.New("h3").Parse(`<h3 class="gemini-heading-3">{{.}}</h3>`))
|
||||
listItemTmpl = template.Must(template.New("listItem").Parse(`<p class="gemini-list-item">• {{.}}</p>`))
|
||||
blockquoteTmpl = template.Must(template.New("blockquote").Parse(`<blockquote class="gemini-blockquote">{{.}}</blockquote>`))
|
||||
preformattedTmplStart = template.Must(template.New("preformatted").Parse(`<pre class="gemini-preformatted">`))
|
||||
preformattedTmplEnd = template.Must(template.New("preformatted").Parse(`</pre>`))
|
||||
linkTmpl = template.Must(template.New("link").Parse(`<div class="gemini-link-container"><a href="{{.URL}}">{{.Description}}</a></div>`))
|
||||
)
|
||||
|
||||
96
test_files/1.gmi
Normal file
96
test_files/1.gmi
Normal file
@@ -0,0 +1,96 @@
|
||||
# pollux.casa 🟊.⌂
|
||||
|
||||
```ascii-art pollux.casa
|
||||
_______ _______ ___ ___ __ __ __ __ ___
|
||||
| | | | | | | | | | |_| | / \
|
||||
| _ | _ | | | | | | | | | / \
|
||||
| |_| | | | | | | | | |_| | | / _ _ \
|
||||
| ___| |_| | |___| |___| || | ___ ||_| |_||
|
||||
| | | | | | | _ | | | | _ |
|
||||
|___| |_______|_______|_______|_______|__| |__| |___| |__|_|__|
|
||||
```
|
||||
|
||||
=> /index.fr.gmi 🏳️ Version française
|
||||
|
||||
## A gemini capsules free hosting
|
||||
You want to create a gemini capsule, but do not want to manage a server?
|
||||
You're welcome !
|
||||
|
||||
This service is proposed and managed by Adële
|
||||
=> gemini://adele.pollux.casa/ Adële's capsule
|
||||
|
||||
If your "pseudo" is still available, your capsule will be created and reachable on this address:
|
||||
```
|
||||
gemini://"pseudo".pollux.casa/
|
||||
```
|
||||
Also accessible in http/https if you want to share with persons that do not know gemini yet:
|
||||
```
|
||||
https://"pseudo".pollux.casa/
|
||||
```
|
||||
|
||||
Go on board, it will 🚀
|
||||
=> requestaccount.gmi How to request an account
|
||||
=> content.gmi How to edit your gemini capsule content
|
||||
|
||||
## News
|
||||
=> /gemlog/ news gemlog
|
||||
|
||||
## Why pollux.casa domain name?
|
||||
* 🟊 Pollux, because it is one of main stars in Gemini constellation
|
||||
* ⌂ Casa, because it will become your home in this constellation
|
||||
|
||||
## Hardware & software
|
||||
pollux.casa is hosted on a small Intel NUC machine
|
||||
* Processor Celeron N2830 @2.16GHz (64bits)
|
||||
* 4 Gb RAM
|
||||
* Disk SATA 1 Tb (5400rpm)
|
||||
* ADSL connection 6Mb/s up and 23Mb/s down
|
||||
* OS Archlinux
|
||||
* Gemserv Gemini server & Lighttpd + Ergol-http
|
||||
|
||||
=> img/nuc.jpg pollux.casa server !
|
||||
|
||||
## Charter and policy
|
||||
This service is offered freely without service guarantee. However, this service will be supplied in accordance to the Charter of CHATONS.
|
||||
|
||||
CHATONS is a collective of independant, transparent, open, neutral and ethical hosters providing FLOSS-based online services.
|
||||
|
||||
=> https://www.chatons.org/en/manifeste Manifesto of CHATONS
|
||||
=> https://www.chatons.org/en/charte Charter of CHATONS
|
||||
|
||||
pollux.casa is not an official entity so, it cannot officially be member of CHATONS.
|
||||
|
||||
I'm French and lives in France, so French laws will be applied to this service.
|
||||
|
||||
## Free to leave pollux.casa
|
||||
If you decide to self-host your capsule, it is possible download your capsule (of course, you have uploaded it) and to redirect "pseudo.pollux.casa" to your own domain and machine. I'll be happy to have helped you to start gemini experience.
|
||||
|
||||
## Contact
|
||||
You can join me on the Fediverse:
|
||||
=> https://social.pollux.casa/@adele @adele@social.pollux.casa
|
||||
Or by email:
|
||||
=> /adele_at_pollux.casa.pub.asc get openpgp pub key of adele(at)pollux.casa
|
||||
|
||||
|
||||
``` asci-art astronaut
|
||||
_____
|
||||
./ \. _
|
||||
/ .-""-\ _/ \
|
||||
.-| /:. | | |
|
||||
| \ |:. /.-'-./
|
||||
| .-'-;:__.' =/
|
||||
.'= *=| _.='
|
||||
/ _. | 🚀 ;
|
||||
;-.-'| \ |
|
||||
/ | \ _\ _\
|
||||
\__/'._;. ==' ==\
|
||||
\ \ |
|
||||
/ / /
|
||||
/-._/-._/
|
||||
\ `\ \
|
||||
`-._/._/
|
||||
```
|
||||
│
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Time: 2.569 ms
|
||||
Reference in New Issue
Block a user