diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-09 20:44:58 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-09 20:44:58 +0300 |
| commit | 3e61d09873065f5342efc414ee3ea0d5fdc4c767 (patch) | |
| tree | 7d0ac51cfb41b4774db6292deeb0cc3dce93cf07 /internal/processor/txt.go | |
| parent | 51f95f88ca78471a50b3fc62dbcea8edb609dc80 (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.go | 103 |
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 +} |
