527 lines
13 KiB
Go
527 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
SPEC_URL string = "gemini://gemini.circumlunar.space:1965/docs/specification.gmi"
|
|
VERSION string = "0.2.0"
|
|
WARNING int = iota
|
|
ERROR
|
|
)
|
|
|
|
type issue struct {
|
|
message string
|
|
context string
|
|
lineNumber int
|
|
errType int
|
|
}
|
|
|
|
var inPreBlock bool = false
|
|
var lineNumber int = 0
|
|
var issues []issue
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func isWhiteSpace(c rune) bool {
|
|
switch c {
|
|
case ' ', '\t':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func firstIndexNotChar(ln string, ch rune) int {
|
|
for i, c := range ln {
|
|
if c != ch {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Makes sure only 3 heading levels are used, adds a space after the heading declaration, before the text
|
|
func heading(ln string) string {
|
|
ln = strings.TrimSpace(ln)
|
|
if strings.HasPrefix(ln, "####") {
|
|
length := min(len(ln), 50)
|
|
issues = append(issues, issue{"Too many heading levels", ln[:length], lineNumber, ERROR})
|
|
}
|
|
endHeading := firstIndexNotChar(ln, '#')
|
|
return fmt.Sprintf("%s %s", ln[:endHeading], strings.TrimSpace(ln[endHeading:]))
|
|
}
|
|
|
|
func link(ln string) string {
|
|
ln = strings.TrimSpace(ln)
|
|
if len(ln) < 3 {
|
|
issues = append(issues, issue{"Link initiated but not followed with a url", ln, lineNumber, ERROR})
|
|
}
|
|
linkln := strings.TrimSpace(ln[2:])
|
|
linkln = strings.Replace(linkln, "\t", " ", -1)
|
|
split := strings.SplitN(linkln, " ", 2)
|
|
if len(split) == 1 {
|
|
return fmt.Sprintf("=> %s", strings.TrimSpace(split[0]))
|
|
}
|
|
return fmt.Sprintf("=> %s %s", strings.TrimSpace(split[0]), strings.TrimSpace(split[1]))
|
|
|
|
}
|
|
|
|
// Makes sure there is one space between the list declaration and the text
|
|
func list(ln string) string {
|
|
ln = strings.TrimSpace(ln)
|
|
if len(ln) < 2 {
|
|
return ln
|
|
}
|
|
return fmt.Sprintf("* %s", strings.TrimSpace(ln[1:]))
|
|
}
|
|
|
|
// preBlockToggle toggles the preBlock state as well as providing proper trimming of extra space
|
|
// if text is provided with an opening ``` a space is put in between the ``` and the text
|
|
func preBlockToggle(ln string) string {
|
|
ln = strings.TrimSpace(ln)
|
|
inPreBlock = !inPreBlock
|
|
if len(ln) > 3 {
|
|
alt := strings.TrimSpace(ln[3:])
|
|
|
|
if len(alt) == 0 {
|
|
return "```"
|
|
}
|
|
|
|
// If there is alt text and we are at a closing ```:
|
|
if !inPreBlock {
|
|
length := min(len(ln), 50)
|
|
issues = append(issues, issue{"Alt text cannot be included when closing a block, put it with the opening ```", ln[:length], lineNumber, ERROR})
|
|
return ln
|
|
}
|
|
// If there is alt text and this is opening a ```:
|
|
return fmt.Sprintf("``` %s", strings.TrimSpace(ln[3:]))
|
|
}
|
|
return ln
|
|
}
|
|
|
|
// preText just removes excess space on the right hand side
|
|
func preText(ln string) string {
|
|
return strings.TrimRight(ln, " \t")
|
|
}
|
|
|
|
// nonPreText normalizes spacing by eliminating instances of multiple spaces or tabs in a row.
|
|
func nonPreText(ln string) string {
|
|
ln = strings.TrimSpace(ln)
|
|
var output strings.Builder
|
|
ws := false
|
|
for _, c := range ln {
|
|
if !isWhiteSpace(c) {
|
|
ws = false
|
|
} else if ws {
|
|
continue
|
|
} else {
|
|
ws = true
|
|
}
|
|
output.WriteRune(c)
|
|
}
|
|
|
|
return output.String()
|
|
}
|
|
|
|
func lineRouter(ln string) string {
|
|
if len(ln) == 0 {
|
|
return ln
|
|
}
|
|
|
|
// Handle special strings
|
|
if ln[0] == '#' {
|
|
return heading(ln)
|
|
} else if ln[0] == '*' {
|
|
return list(ln)
|
|
} else if strings.HasPrefix(ln, "```") {
|
|
return preBlockToggle(ln)
|
|
} else if strings.HasPrefix(ln, "=>") {
|
|
return link(ln)
|
|
}
|
|
|
|
// Handle text
|
|
if inPreBlock {
|
|
return preText(ln)
|
|
}
|
|
return nonPreText(ln)
|
|
}
|
|
|
|
func fmtFile(path string) (string, error) {
|
|
b, err := ioutil.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
lines := strings.SplitN(string(b), "\n", -1)
|
|
for i, ln := range lines {
|
|
lines[i] = lineRouter(ln)
|
|
lineNumber = i
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
func printIssues() {
|
|
for _, issue := range issues {
|
|
color := ""
|
|
kind := "Error"
|
|
if issue.errType == ERROR {
|
|
color = "\033[41;97;1m"
|
|
} else {
|
|
kind = "Warning"
|
|
color = "\033[30;103;1m"
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%s%s:\033[0m %s\n%sLine %d:\033[0m %s\n\n", color, kind, issue.message, color, issue.lineNumber, issue.context)
|
|
}
|
|
}
|
|
|
|
func LoadCertificate(cert, key string) (tls.Certificate, error) {
|
|
certificate, err := tls.LoadX509KeyPair(cert, key)
|
|
if err != nil {
|
|
return tls.Certificate{}, err
|
|
}
|
|
return certificate, nil
|
|
}
|
|
|
|
func WriteToFile(path, content string) error {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
f.WriteString(content)
|
|
return nil
|
|
}
|
|
|
|
func convertToHTML(lines []string, lang string, blanks bool) string {
|
|
var out strings.Builder
|
|
header := `
|
|
<!DOCTYPE html>
|
|
<html lang="%s">
|
|
<head>
|
|
<title></title>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width="device-width, initial-scale=1">
|
|
<link rel="stylesheet" href="styles.css">
|
|
</head>
|
|
<body>
|
|
<div id="content"> <!-- here to allow for max-width/centering without altering the body itself -->
|
|
`
|
|
out.WriteString(fmt.Sprintf(header, lang))
|
|
|
|
inConvertPre := false
|
|
inConvertList := false
|
|
art := false
|
|
code := false
|
|
|
|
for _, l := range lines {
|
|
// Handle lists
|
|
if strings.HasPrefix(l, "* ") {
|
|
if !inConvertList {
|
|
inConvertList = true
|
|
out.WriteString("<ul>\n")
|
|
}
|
|
out.WriteString("<li>\n")
|
|
out.WriteString(l)
|
|
out.WriteString("\n</li>\n")
|
|
continue
|
|
} else if inConvertList {
|
|
inConvertList = false
|
|
out.WriteString("</ul>\n")
|
|
}
|
|
|
|
// Handle preformatted blocks
|
|
if strings.HasPrefix(l, "```") && !inConvertPre {
|
|
inConvertPre = true
|
|
alt := ""
|
|
if len(l) > 3 {
|
|
alt = l[3:]
|
|
}
|
|
art = strings.Contains(strings.ToLower(alt), "art")
|
|
code = strings.Contains(strings.ToLower(alt), "code")
|
|
if art {
|
|
out.WriteString(fmt.Sprintf(`<div role="image" aria-label="%s">`, alt))
|
|
out.WriteString("\n<pre>\n")
|
|
} else if code {
|
|
out.WriteString("<pre>\n<code>\n")
|
|
} else {
|
|
out.WriteString("<pre>\n")
|
|
}
|
|
continue
|
|
} else if strings.HasPrefix(l, "```") && inConvertPre {
|
|
inConvertPre = false
|
|
if art {
|
|
out.WriteString("</pre>\n</div>\n")
|
|
} else if code {
|
|
out.WriteString("</code>\n</pre>\n")
|
|
} else {
|
|
out.WriteString("</pre>\n")
|
|
}
|
|
continue
|
|
} else if inConvertPre {
|
|
out.WriteString(l)
|
|
out.WriteRune('\n')
|
|
continue
|
|
}
|
|
|
|
// Handle block quote
|
|
if strings.HasPrefix(l, ">") {
|
|
out.WriteString(fmt.Sprintf("<blockquote>\n\t%s\n</bloackquote>\n", l))
|
|
continue
|
|
}
|
|
|
|
// Handle headings
|
|
if strings.HasPrefix(l, "####") {
|
|
out.WriteString(l)
|
|
continue
|
|
} else if strings.HasPrefix(l, "###") {
|
|
out.WriteString(fmt.Sprintf("<h3>\n\t%s\n</h3>\n", l))
|
|
continue
|
|
} else if strings.HasPrefix(l, "##") {
|
|
out.WriteString(fmt.Sprintf("<h2>\n\t%s\n</h2>\n", l))
|
|
continue
|
|
} else if strings.HasPrefix(l, "#") {
|
|
out.WriteString(fmt.Sprintf("<h1>\n\t%s\n</h1>\n", l))
|
|
continue
|
|
}
|
|
|
|
// Handle links
|
|
if strings.HasPrefix(l, "=>") {
|
|
if len(l) < 3 {
|
|
continue
|
|
}
|
|
split := strings.SplitN(strings.TrimSpace(l[2:]), " ", 2)
|
|
u := strings.TrimSpace(split[0])
|
|
txt := u
|
|
if len(split) > 1 {
|
|
txt = strings.TrimSpace(split[1])
|
|
}
|
|
out.WriteString(fmt.Sprintf("<p class=\"link-block\">\n\t<a href=\"%s\">\n\t\t%s\n\t</a>\n</p>\n", u, txt))
|
|
continue
|
|
}
|
|
|
|
// Regular text
|
|
if len(l) > 0 {
|
|
out.WriteString("<p>\n\t")
|
|
out.WriteString(l)
|
|
out.WriteString("\n</p>\n")
|
|
} else {
|
|
if blanks {
|
|
out.WriteString("<br>\n")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
out.WriteString("\t\t</div>\n\t</body>\n</html>\n")
|
|
|
|
return out.String()
|
|
}
|
|
|
|
func convertTo(file, format, lang string, blanks bool, outPath string) error {
|
|
var output string
|
|
lines := strings.Split(file, "\n")
|
|
|
|
switch format {
|
|
case "html":
|
|
output = convertToHTML(lines, lang, blanks)
|
|
default:
|
|
return fmt.Errorf("Unknown conversion format %q", format)
|
|
}
|
|
if outPath == "" {
|
|
fmt.Print(output)
|
|
} else {
|
|
err := WriteToFile(outPath, output)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func get(resource, cert, key string, headerOnly, currentSpec bool) (string, error) {
|
|
hasScheme := strings.Contains(resource, "://")
|
|
if !hasScheme {
|
|
resource = "gemini://" + resource
|
|
}
|
|
u, err := url.Parse(resource)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if u.Scheme != "gemini" {
|
|
return "", fmt.Errorf("URL scheme must be 'gemini'")
|
|
}
|
|
if u.Port() == "" {
|
|
u.Host = u.Host + ":1965"
|
|
}
|
|
conf := &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
InsecureSkipVerify: true,
|
|
}
|
|
// TODO add client cert if available
|
|
|
|
conn, err := tls.Dial("tcp", u.Host, conf)
|
|
if err != nil {
|
|
return "", fmt.Errorf("TLS Dial Error: %s", err.Error())
|
|
}
|
|
defer conn.Close()
|
|
send := u.String() + "\r\n"
|
|
|
|
_, err = conn.Write([]byte(send))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
result, err := ioutil.ReadAll(conn)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
text := string(result)
|
|
endHeader := strings.Index(text, "\n")
|
|
|
|
if endHeader < 1 {
|
|
return "", fmt.Errorf("Invalid response from server")
|
|
}
|
|
|
|
if text[0] == '3' {
|
|
newUrl := strings.TrimSpace(text[3:endHeader])
|
|
return get(newUrl, cert, key, headerOnly, currentSpec)
|
|
}
|
|
|
|
head := strings.TrimSpace(text[:endHeader])
|
|
body := strings.TrimSpace(text[endHeader:])
|
|
if headerOnly {
|
|
return head, nil
|
|
}
|
|
|
|
if body == "" {
|
|
return "", fmt.Errorf("Error: Body was empty. Response header:\n%s\n", head)
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
func main() {
|
|
fmtCmd := flag.NewFlagSet("fmt", flag.ExitOnError)
|
|
fmtOut := fmtCmd.String("o", "", "Path to output file")
|
|
fmtSupress := fmtCmd.Bool("quiet", false, "Supress error messaging")
|
|
|
|
getCmd := flag.NewFlagSet("get", flag.ExitOnError)
|
|
getOut := getCmd.String("o", "", "Path to output file")
|
|
getMime := getCmd.Bool("header", false, "Only retrieve header")
|
|
getCert := getCmd.String("cert", "", "Path to certificate file")
|
|
getKey := getCmd.String("key", "", "Path to key file")
|
|
|
|
specCmd := flag.NewFlagSet("spec", flag.ExitOnError)
|
|
specOut := specCmd.String("o", "", "Path to output file")
|
|
|
|
convertCmd := flag.NewFlagSet("convert", flag.ExitOnError)
|
|
convertFormat := convertCmd.String("to", "html", "Valid: html|text|markdown")
|
|
convertLang := convertCmd.String("lang", "en", "The document's language abbreviation for the html lang attribute")
|
|
convertBlankLines := convertCmd.Bool("blanks", false, "Preserve empty lines in the converted document")
|
|
convertOut := convertCmd.String("o", "", "Path to output file")
|
|
|
|
if len(os.Args) < 2 {
|
|
fmt.Fprintf(os.Stderr, "Gemini Tool v%s\n", VERSION)
|
|
fmt.Fprintf(os.Stderr, "Expected command: convert, fmt, get, spec, or version\n")
|
|
flag.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch os.Args[1] {
|
|
case "convert":
|
|
convertCmd.Parse(os.Args[2:])
|
|
if len(convertCmd.Args()) != 1 {
|
|
fmt.Printf("Incorrect number of positional arguments expected 1, got %d\n\ngemini convert [flags] [filepath]\n\n", len(fmtCmd.Args()))
|
|
convertCmd.Usage()
|
|
os.Exit(1)
|
|
}
|
|
text, err := fmtFile(convertCmd.Args()[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
err = convertTo(text, *convertFormat, *convertLang, *convertBlankLines, *convertOut)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
case "fmt":
|
|
fmtCmd.Parse(os.Args[2:])
|
|
if len(fmtCmd.Args()) != 1 {
|
|
fmt.Printf("Incorrect number of positional arguments expected 1, got %d\n", len(fmtCmd.Args()))
|
|
os.Exit(1)
|
|
}
|
|
text, err := fmtFile(fmtCmd.Args()[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
if *fmtOut == "" {
|
|
fmt.Print(text)
|
|
fmt.Fprint(os.Stderr, "\n") // So that the terminal input is always on a new line, but this LF doesnt get piped into a document
|
|
} else {
|
|
err = WriteToFile(*fmtOut, text)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
if !*fmtSupress {
|
|
printIssues()
|
|
}
|
|
case "get":
|
|
getCmd.Parse(os.Args[2:])
|
|
if len(getCmd.Args()) != 1 {
|
|
fmt.Printf("Incorrect number of positional arguments expected 1, got %d\n", len(fmtCmd.Args()))
|
|
os.Exit(1)
|
|
}
|
|
text, err := get(getCmd.Args()[0], *getCert, *getKey, *getMime, false)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
os.Exit(1)
|
|
}
|
|
if *getOut == "" {
|
|
fmt.Print(text)
|
|
fmt.Print("\n")
|
|
} else {
|
|
// TODO handle file writing
|
|
}
|
|
case "spec":
|
|
specCmd.Parse(os.Args[2:])
|
|
if len(getCmd.Args()) != 0 {
|
|
fmt.Printf("Incorrect number of positional arguments expected 0, got %d\n", len(fmtCmd.Args()))
|
|
os.Exit(1)
|
|
}
|
|
text, err := get(SPEC_URL, "", "", false, true)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
}
|
|
if *specOut == "" {
|
|
fmt.Print(text)
|
|
fmt.Print("\n")
|
|
} else {
|
|
// TODO handle file writing
|
|
}
|
|
case "version":
|
|
fmt.Printf("v%s\n", VERSION)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "Gemini Tool v%s\n", VERSION)
|
|
fmt.Fprintf(os.Stderr, "Expected command: convert, fmt, get, spec, or version\n")
|
|
flag.Parse()
|
|
flag.PrintDefaults()
|
|
}
|
|
}
|