commit c5fb1169b05c9d973a16bf86060617cce3551881 Author: Josip Tišljar Mataušić Date: Wed May 20 16:02:46 2026 +0200 Code diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9eabe63 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.radioz.org/gemtext2gophermap + +go 1.26.3 + +require github.com/mcecode/gemtext-parser v0.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bf472f --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mcecode/gemtext-parser v0.1.0 h1:yyZoQHi0tLxGUI4pIrJH5GpzETCAE8A9GupaQwH1/as= +github.com/mcecode/gemtext-parser v0.1.0/go.mod h1:wj59tK8UABx4+Bp62e4Xp6OfokYCev2izvZmXpm/8Xk= diff --git a/gopherType.go b/gopherType.go new file mode 100644 index 0000000..884502c --- /dev/null +++ b/gopherType.go @@ -0,0 +1,105 @@ +package main + +import ( + "slices" + "mime" + "strings" + "path/filepath" +) + +type GopherTypeCharParams struct { + GophermapFiles []string + GophermapExtensions []string + PromptFileExtensions []string +} + +var plainTextExtensions = []string{ + ".txt", ".md", ".markdown", ".rst", ".adoc", ".text", ".asc", ".utxt", + ".json", ".jsonl", ".yaml", ".yml", ".toml", ".xml", ".xsd", ".xsl", ".xslt", + ".ini", ".conf", ".cfg", ".cnf", ".env", ".properties", ".prefs", ".opts", + ".csv", ".tsv", ".tab", ".log", ".sql", ".ddl", ".dump", + ".go", ".c", ".cpp", ".h", ".hpp", ".cc", ".hh", ".cxx", ".hxx", ".h++", + ".cs", ".fs", ".fsx", ".fsi", ".java", ".kt", ".kts", ".rs", ".swift", + ".py", ".pyw", ".rb", ".rbw", ".pl", ".pm", ".tcl", ".lua", ".php", ".phtml", + ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".dart", + ".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1", ".psm1", ".psd1", + ".css", ".scss", ".sass", ".less", ".styl", ".html", ".htm", ".shtml", ".xhtml", + ".dockerfile", ".makefile", ".cmake", ".gradle", ".gitignore", ".gitconfig", + ".gitattributes", ".editorconfig", ".npmrc", ".babelrc", ".eslintrc", + ".diff", ".patch", ".latex", ".tex", ".bib", ".cls", ".sty", + ".erl", ".hrl", ".ex", ".exs", ".hs", ".lhs", ".scala", ".sc", ".clj", ".cljs", + ".edn", ".lisp", ".lsp", ".scm", ".ss", ".r", ".rmd", ".f", ".f90", ".f95", + ".m", ".matlab", ".jl", ".v", ".vhdl", ".sv", ".proto", ".thrift", + ".tpl", ".tmpl", ".twig", ".blade", ".erb", ".haml", ".pug", ".jade", + ".srt", ".vtt", ".sub", +} +var areAdditionalMimeTypesRegistered = false + +var archives = []string{ + "application/x-archive", "application/x-cpio", "application/x-shar", + "application/x-iso9660-image", "application/x-sbx", "application/x-tar", + "application/x-brotli", "application/x-bzip2", "application/vnd.genozip", + "application/gzip", "application/lzip", "application/x-lzma", + "application/x-lzop", "application/x-snappy-framed", "application/x-xz", + "application/x-compress", "application/x-compress", "application/zstd", + "application/x-7z-compressed", "application/x-7z-compressed", + "application/x-ace-compressed", "application/x-astrotite-afa", + "application/x-alz-compressed", "application/x-freearc", "application/x-arj", + "application/x-b1", "application/vnd.ms-cab-compressed", + "application/x-cfs-compressed", "application/x-dar", "application/x-dgc-compressed", + "application/x-apple-diskimage", "application/x-gca-compressed", + "application/x-lzh", "application/x-lzx", "application/x-rar-compressed", + "application/x-stuffit", "application/x-stuffitx", "application/x-gtar", + "application/x-ms-wim", "application/x-xar", "application/zip", "application/x-zoo", +} + +func (params *GopherTypeCharParams) GopherTypeChar(path string, isDir bool) string { + switch { + case strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://"): + return "h" + case strings.HasPrefix(path, "telnet://"): + return "8" + } + + + extension := filepath.Ext(path) + if isDir || slices.Contains(params.GophermapFiles, filepath.Base(path)) || slices.Contains(params.GophermapExtensions, extension) { + return "1" + } + if slices.Contains(params.PromptFileExtensions, extension) { + return "7" + } + + if !areAdditionalMimeTypesRegistered { + for _, ext := range plainTextExtensions { + mime.AddExtensionType(ext, "text/plain") + } + areAdditionalMimeTypesRegistered = true + } + + mimeType := mime.TypeByExtension(extension) + + switch { + case strings.HasPrefix(mimeType, "text/plain"): + return "0" + case slices.Contains(archives, mimeType): + return "5" + case mimeType == "image/gif": + return "g" + case strings.HasPrefix(mimeType, "image/"): + return "I" + case strings.HasPrefix(mimeType, "audio/"): + return "s" + case mimeType == "application/pdf": + return "P" + case strings.HasPrefix(mimeType, "text/html"): + return "h" + case strings.HasPrefix(mimeType, "text/rtf"): + return "r" + case strings.HasPrefix(mimeType, "text/xml") || strings.HasPrefix(mimeType, "application/xml"): + return "X" + default: + return "9" + } +} + diff --git a/main.go b/main.go new file mode 100644 index 0000000..ca48b77 --- /dev/null +++ b/main.go @@ -0,0 +1,114 @@ +package main + +import( + "fmt" + "os" + "io" + "strings" + "strconv" + p "github.com/mcecode/gemtext-parser" +) + +type State struct { + out io.Writer + maxLineLength int + isInCodeBlock bool + wasPreviousLink bool +} + +func main() { + if len(os.Args) < 3 { + fmt.Printf("Usage: %s input.gmi output_gophermap [max line length (default 69)]\n", os.Args[0]) + return + } + + var asl p.ASL + var err error + if os.Args[1] != "-" { + asl, err = p.ParseFile(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + } else { + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + asl = p.Parse(string(stdin)) + } + + var outputWriter io.Writer + if os.Args[2] != "-" { + file, err := os.Create(os.Args[2]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + defer file.Close() + outputWriter = file + } else { + outputWriter = os.Stdout + } + + s := State{outputWriter, 69, false, false} + if len(os.Args) >= 4 { + maxLineLength, err := strconv.Atoi(os.Args[3]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + s.maxLineLength = maxLineLength + } + + asl.Visit(s.handleElement) +} + +var gopherTypeCharParams = GopherTypeCharParams{ + GophermapFiles: []string{"gophermap"}, + GophermapExtensions: []string{}, + PromptFileExtensions: []string{}, +} + +func (s *State) handleElement(e p.Element) { + switch (e.Type()) { + case p.ElementTypes.Text(): + if e.Text() == "" { return } + if !s.isInCodeBlock { s.out.Write([]byte("\n")) } + s.out.Write([]byte(JoinGophermapLines("i", WrapLines(e.Text(), s.maxLineLength, " "), "\t/FAKE\tNULL\t0"))) + s.wasPreviousLink = false + case p.ElementTypes.Link(): + if !s.wasPreviousLink { + s.out.Write([]byte("\n")) + } + isUrl := strings.Contains(e.URL(), "://") + linkPrefix := "" + if isUrl { + linkPrefix = "URL:" + } + typeChar := gopherTypeCharParams.GopherTypeChar(e.URL(), strings.HasSuffix(e.URL(), "/")) + s.out.Write([]byte(typeChar + e.Text() + "\t" + linkPrefix + e.URL())) + s.wasPreviousLink = true + case p.ElementTypes.Heading(): + s.out.Write([]byte("\n")) + if e.Level() == p.HeadingLevels.Heading() || e.Level() == p.HeadingLevels.SubHeading() { + s.out.Write([]byte("\n\n")) + } + if e.Level() == p.HeadingLevels.SubSubHeading() { + s.out.Write([]byte("\n")) + } + s.out.Write([]byte(JoinGophermapLines("i", WrapLines(e.Text(), s.maxLineLength, " "), "\t/FAKE\tNULL\t0"))) + s.wasPreviousLink = false + case p.ElementTypes.List(): + s.out.Write([]byte(JoinGophermapLines("i- ", WrapLines(e.Text(), s.maxLineLength - 2, " "), "\t/FAKE\tNULL\t0"))) + s.wasPreviousLink = false + case p.ElementTypes.Quote(): + s.out.Write([]byte(JoinGophermapLines("i> ", WrapLines(e.Text(), s.maxLineLength - 2, " "), "\t/FAKE\tNULL\t0"))) + s.wasPreviousLink = false + case p.ElementTypes.PreformatToggle(): + s.isInCodeBlock = !s.isInCodeBlock + s.wasPreviousLink = false + } + s.out.Write([]byte("\n")) +} diff --git a/wrapLines.go b/wrapLines.go new file mode 100644 index 0000000..283872b --- /dev/null +++ b/wrapLines.go @@ -0,0 +1,40 @@ +package main + +import ( + "strings" +) + +// wrapLines returns a slice with every element representing a new line +func WrapLines(input string, maxLineLength int, breakupDelimiter string) []string { + breakupDelimiterLength := len([]rune(breakupDelimiter)) + output := []string{} + + previousExistingLineIndex := -1 + for existingLineIndex, existingLine := range strings.Split(input, "\n") { + words := strings.Split(existingLine, breakupDelimiter) + length := 0 + for _, word := range words { + wordLength := len([]rune(word)) + length += breakupDelimiterLength + wordLength + if length > maxLineLength || len(output) == 0 || previousExistingLineIndex != existingLineIndex { + output = append(output, word) + length = wordLength + } else { + output[len(output) - 1] += breakupDelimiter + word + } + previousExistingLineIndex = existingLineIndex + } + } + + return output +} + +func JoinGophermapLines(typeCharacter string, lines []string, lineEnding string) string { + out := "" + for index, line := range lines { + out += typeCharacter + line + lineEnding + if index < len(lines) - 1 { out += "\n" } + } + return out +} +