summaryrefslogtreecommitdiff
path: root/internal/processor/txt.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-09 20:44:58 +0300
committerPaul Buetow <paul@buetow.org>2026-04-09 20:44:58 +0300
commit3e61d09873065f5342efc414ee3ea0d5fdc4c767 (patch)
tree7d0ac51cfb41b4774db6292deeb0cc3dce93cf07 /internal/processor/txt.go
parent51f95f88ca78471a50b3fc62dbcea8edb609dc80 (diff)
add snonux static microblog generator
Full Go implementation with: - txt/md/image/audio input processing, URL auto-linking in .txt files - Paginated HTML output with Atom feed - 11 visual themes: neon, terminal, synthwave, minimal, brutalist, paper, aurora, matrix, ocean, retro, glass (selectable via --theme flag) - Keyboard navigation (j/k/arrows, Enter modal, h/l page nav) - Shared nav templates (navhints, navmodal, navscript) across all themes - Magefile build automation; integration test suite covering all themes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/processor/txt.go')
-rw-r--r--internal/processor/txt.go103
1 files changed, 103 insertions, 0 deletions
diff --git a/internal/processor/txt.go b/internal/processor/txt.go
new file mode 100644
index 0000000..8381271
--- /dev/null
+++ b/internal/processor/txt.go
@@ -0,0 +1,103 @@
+package processor
+
+import (
+ "fmt"
+ "html"
+ "os"
+ "regexp"
+ "strings"
+)
+
+// urlPattern matches http/https URLs in plain text.
+// Trailing sentence punctuation is stripped separately by stripURLTrailing.
+var urlPattern = regexp.MustCompile(`https?://\S+`)
+
+// processTxt reads a plain-text file and wraps each non-empty paragraph in <p> tags.
+// URLs are automatically converted to clickable <a> links.
+// Non-URL text is HTML-escaped to prevent XSS.
+func processTxt(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("read txt %s: %w", path, err)
+ }
+
+ raw := strings.TrimSpace(string(data))
+ if raw == "" {
+ return "<p></p>", nil
+ }
+
+ // Split on blank lines to get logical paragraphs.
+ paragraphs := strings.Split(raw, "\n\n")
+ var sb strings.Builder
+
+ for _, para := range paragraphs {
+ trimmed := strings.TrimSpace(para)
+ if trimmed == "" {
+ continue
+ }
+ fmt.Fprintf(&sb, "<p>%s</p>\n", formatParagraph(trimmed))
+ }
+
+ return sb.String(), nil
+}
+
+// formatParagraph formats a single paragraph: auto-links URLs, escapes non-URL
+// text, and converts single newlines to <br> line breaks.
+func formatParagraph(para string) string {
+ lines := strings.Split(para, "\n")
+ formatted := make([]string, 0, len(lines))
+
+ for _, line := range lines {
+ if t := strings.TrimSpace(line); t != "" {
+ formatted = append(formatted, autolinkLine(t))
+ }
+ }
+
+ return strings.Join(formatted, "<br>\n")
+}
+
+// autolinkLine escapes non-URL text and wraps detected URLs in <a> tags.
+// Opens in a new tab with rel="noopener noreferrer" for security.
+func autolinkLine(line string) string {
+ locs := urlPattern.FindAllStringIndex(line, -1)
+ if len(locs) == 0 {
+ return html.EscapeString(line)
+ }
+
+ var sb strings.Builder
+ prev := 0
+
+ for _, loc := range locs {
+ sb.WriteString(html.EscapeString(line[prev:loc[0]]))
+
+ rawURL := line[loc[0]:loc[1]]
+ cleanURL := stripURLTrailing(rawURL)
+ trailing := rawURL[len(cleanURL):]
+
+ fmt.Fprintf(&sb, `<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>`,
+ html.EscapeString(cleanURL), html.EscapeString(cleanURL))
+
+ if trailing != "" {
+ sb.WriteString(html.EscapeString(trailing))
+ }
+
+ prev = loc[1]
+ }
+
+ sb.WriteString(html.EscapeString(line[prev:]))
+
+ return sb.String()
+}
+
+// stripURLTrailing removes common sentence-ending punctuation from the end of a
+// URL match. These characters are valid in URLs but almost never appear there
+// at the end in prose (e.g. "Visit https://foo.com." — the "." ends the sentence).
+func stripURLTrailing(u string) string {
+ const cutset = ".,;:!?\"')>]}"
+
+ for len(u) > 0 && strings.ContainsRune(cutset, rune(u[len(u)-1])) {
+ u = u[:len(u)-1]
+ }
+
+ return u
+}