mirror of
https://code.rocketnine.space/tslocum/gmitohtml.git
synced 2024-11-27 09:38:14 +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