mirror of
https://code.rocketnine.space/tslocum/gmitohtml.git
synced 2024-11-27 13:28:15 +01:00
Initial commit
This commit is contained in:
commit
260018cc9d
7 changed files with 373 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.idea/
|
||||||
|
dist/
|
||||||
|
vendor/
|
||||||
|
*.sh
|
||||||
|
/gmitohtml
|
21
.gitlab-ci.yml
Normal file
21
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
image: golang:latest
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- validate
|
||||||
|
- build
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
stage: validate
|
||||||
|
script:
|
||||||
|
- gofmt -l -s -e .
|
||||||
|
- exit $(gofmt -l -s -e . | wc -l)
|
||||||
|
|
||||||
|
vet:
|
||||||
|
stage: validate
|
||||||
|
script:
|
||||||
|
- go vet -composites=false ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: validate
|
||||||
|
script:
|
||||||
|
- go test -race -v ./...
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 Trevor Slocum <trevor@rocketnine.space>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
37
README.md
Normal file
37
README.md
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# gmitohtml
|
||||||
|
[![CI status](https://gitlab.com/tslocum/gmitohtml/badges/master/pipeline.svg)](https://gitlab.com/tslocum/gmitohtml/commits/master)
|
||||||
|
[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space)
|
||||||
|
|
||||||
|
[Gemini](https://gemini.circumlunar.space) to HTML conversion tool and daemon
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
gmitohtml is written in [Go](https://golang.org). Run the following command to
|
||||||
|
download and build gmitohtml from source.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get gitlab.com/tslocum/gmitohtml
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting binary is available as `~/go/bin/gmitohtml`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Convert a single document:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gmitohtml < document.gmi
|
||||||
|
```
|
||||||
|
|
||||||
|
Run as daemon at `http://localhost:8080`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the daemon:
|
||||||
|
gmitohtml --daemon=localhost:8080
|
||||||
|
# Access via browser:
|
||||||
|
xdg-open http://localhost:8080/gemini/twins.rocketnine.space/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Please share issues and suggestions [here](https://gitlab.com/tslocum/gmitohtml/issues).
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module gitlab.com/tslocum/gmitohtml
|
||||||
|
|
||||||
|
go 1.15
|
69
main.go
Normal file
69
main.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitlab.com/tslocum/gmitohtml/pkg/gmitohtml"
|
||||||
|
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openBrowser(url string) {
|
||||||
|
var err error
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
err = exec.Command("xdg-open", url).Start()
|
||||||
|
case "windows":
|
||||||
|
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
|
case "darwin":
|
||||||
|
err = exec.Command("open", url).Start()
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported platform")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var view bool
|
||||||
|
var daemon string
|
||||||
|
flag.BoolVar(&view, "view", false, "open web browser")
|
||||||
|
flag.StringVar(&daemon, "daemon", "", "start daemon on specified address")
|
||||||
|
// TODO option to include response header in page
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if daemon != "" {
|
||||||
|
err := gmitohtml.StartDaemon(daemon)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if view {
|
||||||
|
openBrowser("http://" + daemon)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {} //TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ioutil.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data = gmitohtml.Convert(data, "")
|
||||||
|
|
||||||
|
data = append([]byte("<!DOCTYPE html>\n<html>\n<body>\n"), data...)
|
||||||
|
data = append(data, []byte("\n</body>\n</html>")...)
|
||||||
|
|
||||||
|
if view {
|
||||||
|
openBrowser(string(append([]byte("data:text/html,"), []byte(url.PathEscape(string(data)))...)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(gmitohtml.Convert(data, ""))
|
||||||
|
}
|
217
pkg/gmitohtml/convert.go
Normal file
217
pkg/gmitohtml/convert.go
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
package gmitohtml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidURL is the error returned when the URL is invalid.
|
||||||
|
var ErrInvalidURL = errors.New("invalid URL")
|
||||||
|
|
||||||
|
var daemonAddress string
|
||||||
|
|
||||||
|
func rewriteURL(u string, loc *url.URL) string {
|
||||||
|
if daemonAddress != "" {
|
||||||
|
if strings.HasPrefix(u, "gemini://") {
|
||||||
|
return "http://" + daemonAddress + "/gemini/" + u[9:]
|
||||||
|
} else if strings.Contains(u, "://") {
|
||||||
|
return u
|
||||||
|
} else if loc != nil && len(u) > 0 && !strings.HasPrefix(u, "//") {
|
||||||
|
newPath := u
|
||||||
|
if u[0] != '/' {
|
||||||
|
newPath = path.Join(loc.Path, u)
|
||||||
|
}
|
||||||
|
return "http://" + daemonAddress + "/gemini/" + loc.Host + newPath
|
||||||
|
}
|
||||||
|
return "http://" + daemonAddress + "/gemini/" + u
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert converts text/gemini to text/html.
|
||||||
|
func Convert(page []byte, u string) []byte {
|
||||||
|
var result []byte
|
||||||
|
|
||||||
|
var lastPreformatted bool
|
||||||
|
var preformatted bool
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
parsedURL = nil
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(page))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
l := len(line)
|
||||||
|
if l >= 3 && string(line[0:3]) == "```" {
|
||||||
|
preformatted = !preformatted
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if preformatted != lastPreformatted {
|
||||||
|
if preformatted {
|
||||||
|
result = append(result, []byte("<pre>\n")...)
|
||||||
|
} else {
|
||||||
|
result = append(result, []byte("</pre>\n")...)
|
||||||
|
}
|
||||||
|
lastPreformatted = preformatted
|
||||||
|
}
|
||||||
|
|
||||||
|
if preformatted {
|
||||||
|
result = append(result, line...)
|
||||||
|
result = append(result, []byte("\n")...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if l >= 7 && bytes.HasPrefix(line, []byte("=> ")) {
|
||||||
|
split := bytes.SplitN(line[3:], []byte(" "), 2)
|
||||||
|
if len(split) == 2 {
|
||||||
|
link := append([]byte(`<a href="`), rewriteURL(string(split[0]), parsedURL)...)
|
||||||
|
link = append(link, []byte(`">`)...)
|
||||||
|
link = append(link, split[1]...)
|
||||||
|
link = append(link, []byte(`</a>`)...)
|
||||||
|
result = append(result, link...)
|
||||||
|
result = append(result, []byte("<br>")...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heading := 0
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
if line[i] == '#' {
|
||||||
|
heading++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if heading > 0 {
|
||||||
|
result = append(result, []byte(fmt.Sprintf("<h%d>%s</h%d>", heading, line, heading))...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, line...)
|
||||||
|
result = append(result, []byte("<br>")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch downloads and converts a Gemini page.
|
||||||
|
func Fetch(u string, clientCertFile string, clientCertKey string) ([]byte, []byte, error) {
|
||||||
|
if u == "" {
|
||||||
|
return nil, nil, ErrInvalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
requestURL, err := url.ParseRequestURI(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if requestURL.Scheme == "" {
|
||||||
|
requestURL.Scheme = "gemini"
|
||||||
|
}
|
||||||
|
if strings.IndexRune(requestURL.Host, ':') == -1 {
|
||||||
|
requestURL.Host += ":1965"
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{}
|
||||||
|
|
||||||
|
conn, err := tls.Dial("tcp", requestURL.Host, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request header
|
||||||
|
conn.Write([]byte(requestURL.String() + "\r\n"))
|
||||||
|
|
||||||
|
data, err := ioutil.ReadAll(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
firstNewLine := -1
|
||||||
|
l := len(data)
|
||||||
|
if l > 2 {
|
||||||
|
for i := 1; i < l; i++ {
|
||||||
|
if data[i] == '\n' && data[i-1] == '\r' {
|
||||||
|
firstNewLine = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var header []byte
|
||||||
|
if firstNewLine > -1 {
|
||||||
|
header = data[:firstNewLine]
|
||||||
|
data = data[firstNewLine+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(header, []byte("text/html")) {
|
||||||
|
return header, data, nil
|
||||||
|
}
|
||||||
|
return header, Convert(data, requestURL.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRequest(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
defer request.Body.Close()
|
||||||
|
if request.URL == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pathSplit := strings.Split(request.URL.Path, "/")
|
||||||
|
if len(pathSplit) < 2 || pathSplit[1] != "gemini" {
|
||||||
|
writer.Write([]byte("Error: invalid protocol, only Gemini is supported"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.ParseRequestURI("gemini://" + strings.Join(pathSplit[2:], "/"))
|
||||||
|
if err != nil {
|
||||||
|
writer.Write([]byte("Error: invalid URL"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
header, data, err := Fetch(u.String(), "", "")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(writer, "Error: failed to fetch %s: %s", u, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) > 0 && header[0] == '3' {
|
||||||
|
split := bytes.SplitN(header, []byte(" "), 2)
|
||||||
|
if len(split) == 2 {
|
||||||
|
writer.Header().Set("Location", rewriteURL(string(split[1]), request.URL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(header) > 3 && !bytes.HasPrefix(header[3:], []byte("text/gemini")) {
|
||||||
|
writer.Header().Set("Content-type", string(header[3:]))
|
||||||
|
} else {
|
||||||
|
writer.Header().Set("Content-type", "text/html; charset=utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write([]byte("<!DOCTYPE html>\n<html>\n<body>\n"))
|
||||||
|
writer.Write(data)
|
||||||
|
writer.Write([]byte("\n</body>\n</html>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDaemon starts the page conversion daemon.
|
||||||
|
func StartDaemon(address string) error {
|
||||||
|
daemonAddress = address
|
||||||
|
|
||||||
|
http.HandleFunc("/", handleRequest)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Fatal(http.ListenAndServe(address, nil))
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
Loading…
Reference in a new issue