Browse Source

Format entire tree

Benton Edmondson 1 year ago
parent
commit
2cd5a935e6
36 changed files with 3663 additions and 3535 deletions
  1. 6 0
      .vscode/settings.json
  2. 324 308
      ansi/ansi.go
  3. 265 265
      ansi/ansi_test.go
  4. 29 22
      client/client.go
  5. 61 61
      config/config.go
  6. 57 57
      feed/feed.go
  7. 65 65
      feed/feed_test.go
  8. 64 64
      gemtext/gemtext.go
  9. 38 38
      gemtext/gemtext_test.go
  10. 278 272
      hypertext/hypertext.go
  11. 155 155
      hypertext/hypertext_test.go
  12. 276 272
      jtp/jtp.go
  13. 58 58
      jtp/jtp_test.go
  14. 24 24
      markdown/markdown.go
  15. 26 26
      markdown/markdown_test.go
  16. 57 55
      mime/mime.go
  17. 71 71
      mime/mime_test.go
  18. 130 130
      object/object.go
  19. 166 128
      object/object_test.go
  20. 25 25
      plaintext/plaintext.go
  21. 24 24
      plaintext/plaintext_test.go
  22. 127 123
      pub/activity.go
  23. 220 206
      pub/actor.go
  24. 149 143
      pub/collection.go
  25. 214 207
      pub/common.go
  26. 43 43
      pub/failure.go
  27. 22 22
      pub/interfaces.go
  28. 161 157
      pub/link.go
  29. 40 36
      pub/post.go
  30. 28 28
      pub/user-input.go
  31. 41 39
      render/render.go
  32. 20 20
      render/render_test.go
  33. 111 111
      splicer/splicer.go
  34. 76 76
      style/style.go
  35. 201 193
      ui/ui.go
  36. 11 11
      util/util.go

+ 6 - 0
.vscode/settings.json

@@ -0,0 +1,6 @@
+{
+    "[go]": {
+        // format by running go fmt mimicry/... repeatedly until fixed point is reached
+        "editor.formatOnSave": false
+    }
+}

+ 324 - 308
ansi/ansi.go

@@ -1,308 +1,324 @@
-package ansi
-
-import (
-	"regexp"
-	"strings"
-	"unicode"
-)
-
-// TODO: probably make a type Expanded which is an array of
-// structs to make things more readable and type safe
-
-// TODO: all source code should be ascii so it's more readable,
-// unicode characters should use escape codes
-
-func expand(text string) [][]string {
-	r := regexp.MustCompile(`(?s)((?:\x1b\[.*?m)*)(.)(?:\x1b\[0m)?`)
-	return r.FindAllStringSubmatch(text, -1)
-}
-
-func collapse(expanded [][]string) string {
-	output := ""
-	for _, match := range expanded {
-		output += match[0]
-	}
-	return output
-}
-
-func Apply(text string, style string) string {
-	expanded := expand(text)
-	result := ""
-	for _, match := range expanded {
-		prefix := match[1]
-		letter := match[2]
-
-		if letter == "\n" {
-			result += "\n"
-			continue
-		}
-
-		result += "\x1b[" + style + "m" + prefix + letter + "\x1b[0m"
-	}
-	return result
-}
-
-func Indent(text string, prefix string, includeFirst bool) string {
-	expanded := expand(text)
-	result := ""
-
-	if includeFirst {
-		result = prefix
-	}
-
-	for _, match := range expanded {
-		full := match[0]
-		letter := match[2]
-
-		if letter == "\n" {
-			result += "\n" + prefix
-			continue
-		}
-
-		result += full
-	}
-	return result
-}
-
-const suffix = " "
-func Pad(text string, length int) string {
-	expanded := expand(text)
-	result := ""
-	lineLength := 0
-
-	for _, match := range expanded {
-		full := match[0]
-		letter := match[2]
-
-		if letter == "\n" {
-			amount := length - lineLength
-			if amount <= 0 {
-				result += "\n"
-				lineLength = 0
-				continue
-			}
-			result += strings.Repeat(suffix, amount) + "\n"
-			lineLength = 0
-			continue
-		}
-
-		lineLength += 1
-		result += full
-	}
-
-	/* Final line */
-	amount := length - lineLength
-	if amount > 0 {
-		result += strings.Repeat(suffix, amount)
-	}
-
-	return result
-}
-
-/*
-	I am not convinced this works perfectly, but it is well-tested,
-	so I will call it good for now.
-*/
-func Wrap(text string, length int) string {
-	expanded := expand(text)
-	result := []string{}
-	var line, space, word string
-	var lineLength, spaceLength, wordLength int
-
-	for _, match := range expanded {
-		full := match[0]
-		letter := match[2]
-
-		/* TODO: I need to find the list of non-breaking whitespace characters
-			to exclude from this conditional */
-		if !unicode.IsSpace([]rune(letter)[0]) {
-			if wordLength == length {
-				/*
-					Word fills an entire line; push it as a line
-					(we know this won't clobber stuff in `line`, because the word has
-					already necessarily forced line to be pushed)
-				*/
-				result = append(result, word)
-				line = ""; lineLength = 0
-				space = ""; spaceLength = 0
-				word = ""; wordLength = 0
-			}
-			
-			if lineLength + spaceLength + wordLength >= length {
-				/* The word no longer fits on the current line; push the current line */
-				result = append(result, line)
-				line = ""; lineLength = 0
-				space = ""; spaceLength = 0
-			}
-
-			word += full; wordLength += 1
-			continue
-		}
-
-		/* This means whitespace has been encountered; if there's a word, add it to the line */
-		if wordLength > 0 {
-			line += space + word; lineLength += spaceLength + wordLength
-			space = ""; spaceLength = 0
-			word = ""; wordLength = 0
-		}
-
-		if letter == "\n" {
-			/*
-				If the spaces can be jammed into the line, add them.
-				This ensures that Wrap(Pad(*)) doesn't eliminate the
-				padding.
-			*/
-			if lineLength + spaceLength <= length {
-				line += space; lineLength += spaceLength
-			}
-
-			/* Add the current line as-is and clear everything */
-			result = append(result, line)
-			line = ""; lineLength = 0
-			space = ""; spaceLength = 0
-			word = ""; wordLength = 0
-		} else {
-			space += full; spaceLength += 1
-		}
-	}
-
-	/* Cleanup */
-	if wordLength > 0 {
-		line += space + word; lineLength += spaceLength + wordLength
-	}
-	finalLetter := ""
-	if len(expanded) > 0 {
-		finalLetter = expanded[len(expanded)-1][2]
-	}
-	if lineLength > 0 || finalLetter == "\n" {
-		result = append(result, line)
-	}
-
-	return strings.Join(result, "\n")
-}
-
-func DumbWrap(text string, width int) string {
-	expanded := expand(text)
-	result := ""
-	currentLineLength := 0
-
-	for _, match := range expanded {
-		full := match[0]
-		letter := match[2]
-
-		if letter == "\n" {
-			currentLineLength = 0
-			result += "\n"
-			continue
-		}
-
-		if currentLineLength == width {
-			currentLineLength = 0
-			result += "\n"
-		}
-
-		result += full
-		currentLineLength += 1
-	}
-	return result
-}
-
-/*
-	Limits `text` to the given `height` and `width`, adding an
-	ellipsis to the end and omitting trailing whitespace-only lines
-*/
-// TODO: this function could be optimized into just one loop
-func Snip(text string, width, height int, ellipsis string) string {
-	snipped := []string{}
-
-	/* This split is fine because newlines are 
-	   guaranteed to not be wrapped in ansi codes */
-	lines := strings.Split(text, "\n")
-
-	requiresEllipsis := false
-
-	if len(lines) < height {
-		height = len(lines)
-	} else {
-		requiresEllipsis = true
-	}
-
-	for i := height - 1; i >= 0; i -= 1 {
-		line := expand(lines[i])
-		if len(snipped) == 0 {
-			if lineIsOnlyWhitespace(line) {
-				requiresEllipsis = true
-				continue
-			}
-
-			/* Remove last character to make way for ellipsis */
-			if len(line) == width {
-				line = line[:len(line)-1]
-			}
-		}
-
-		snipped = append([]string{collapse(line)}, snipped...)
-	}
-
-	output := strings.Join(snipped, "\n")
-
-	if requiresEllipsis {
-		output += ellipsis
-	}
-	
-	return output
-}
-
-func lineIsOnlyWhitespace(expanded [][]string) bool {
-	for _, match := range expanded {
-		if !unicode.IsSpace([]rune(match[2])[0]) {
-			return false
-		}
-	}
-
-	return true
-}
-
-func Height(text string) uint {
-	return uint(strings.Count(text, "\n")) + 1
-}
-
-func CenterVertically(prefix, centered, suffix string, height uint) string {
-	prefixHeight, centeredHeight, suffixHeight := Height(prefix), Height(centered), Height(suffix)
-	if height < centeredHeight {
-		panic("screen is too small to vertically center text within it")
-	}
-	totalBufferSize := height - centeredHeight
-	topBufferSize := totalBufferSize / 2
-	bottomBufferSize := topBufferSize + totalBufferSize % 2
-
-	if topBufferSize > prefixHeight {
-		prefix = strings.Repeat("\n", int(topBufferSize - prefixHeight)) + prefix
-	} else if topBufferSize < prefixHeight {
-		prefix = strings.Join(strings.Split(prefix, "\n")[prefixHeight-topBufferSize:], "\n")
-	}
-
-	if bottomBufferSize > suffixHeight {
-		suffix += strings.Repeat("\n", int(bottomBufferSize - suffixHeight))
-	} else if bottomBufferSize < suffixHeight {
-		suffix = strings.Join(strings.Split(suffix, "\n")[:bottomBufferSize], "\n")
-	}
-
-	return prefix + "\n" + centered + "\n" + suffix
-}
-
-/*
-	TODO:
-		add `Scrub` function that removes all ANSI codes from text
-		(this will be used when people redirect output to file)
-
-		add `Squash` function that converts newlines to spaces
-		(this will be used to prevent newlines from appearing
-		in things like names and titles), and removes control
-		characters
-
-		add `StrictWrap` function that wraps not based on whitespace
-		but strictly on length (this will be used for code blocks)
-
-		move `RemoveControlCharacters` from render to here 
-*/
+package ansi
+
+import (
+	"regexp"
+	"strings"
+	"unicode"
+)
+
+// TODO: probably make a type Expanded which is an array of
+// structs to make things more readable and type safe
+
+// TODO: all source code should be ascii so it's more readable,
+// unicode characters should use escape codes
+
+func expand(text string) [][]string {
+	r := regexp.MustCompile(`(?s)((?:\x1b\[.*?m)*)(.)(?:\x1b\[0m)?`)
+	return r.FindAllStringSubmatch(text, -1)
+}
+
+func collapse(expanded [][]string) string {
+	output := ""
+	for _, match := range expanded {
+		output += match[0]
+	}
+	return output
+}
+
+func Apply(text string, style string) string {
+	expanded := expand(text)
+	result := ""
+	for _, match := range expanded {
+		prefix := match[1]
+		letter := match[2]
+
+		if letter == "\n" {
+			result += "\n"
+			continue
+		}
+
+		result += "\x1b[" + style + "m" + prefix + letter + "\x1b[0m"
+	}
+	return result
+}
+
+func Indent(text string, prefix string, includeFirst bool) string {
+	expanded := expand(text)
+	result := ""
+
+	if includeFirst {
+		result = prefix
+	}
+
+	for _, match := range expanded {
+		full := match[0]
+		letter := match[2]
+
+		if letter == "\n" {
+			result += "\n" + prefix
+			continue
+		}
+
+		result += full
+	}
+	return result
+}
+
+const suffix = " "
+
+func Pad(text string, length int) string {
+	expanded := expand(text)
+	result := ""
+	lineLength := 0
+
+	for _, match := range expanded {
+		full := match[0]
+		letter := match[2]
+
+		if letter == "\n" {
+			amount := length - lineLength
+			if amount <= 0 {
+				result += "\n"
+				lineLength = 0
+				continue
+			}
+			result += strings.Repeat(suffix, amount) + "\n"
+			lineLength = 0
+			continue
+		}
+
+		lineLength += 1
+		result += full
+	}
+
+	/* Final line */
+	amount := length - lineLength
+	if amount > 0 {
+		result += strings.Repeat(suffix, amount)
+	}
+
+	return result
+}
+
+/*
+I am not convinced this works perfectly, but it is well-tested,
+so I will call it good for now.
+*/
+func Wrap(text string, length int) string {
+	expanded := expand(text)
+	result := []string{}
+	var line, space, word string
+	var lineLength, spaceLength, wordLength int
+
+	for _, match := range expanded {
+		full := match[0]
+		letter := match[2]
+
+		/* TODO: I need to find the list of non-breaking whitespace characters
+		to exclude from this conditional */
+		if !unicode.IsSpace([]rune(letter)[0]) {
+			if wordLength == length {
+				/*
+					Word fills an entire line; push it as a line
+					(we know this won't clobber stuff in `line`, because the word has
+					already necessarily forced line to be pushed)
+				*/
+				result = append(result, word)
+				line = ""
+				lineLength = 0
+				space = ""
+				spaceLength = 0
+				word = ""
+				wordLength = 0
+			}
+
+			if lineLength+spaceLength+wordLength >= length {
+				/* The word no longer fits on the current line; push the current line */
+				result = append(result, line)
+				line = ""
+				lineLength = 0
+				space = ""
+				spaceLength = 0
+			}
+
+			word += full
+			wordLength += 1
+			continue
+		}
+
+		/* This means whitespace has been encountered; if there's a word, add it to the line */
+		if wordLength > 0 {
+			line += space + word
+			lineLength += spaceLength + wordLength
+			space = ""
+			spaceLength = 0
+			word = ""
+			wordLength = 0
+		}
+
+		if letter == "\n" {
+			/*
+				If the spaces can be jammed into the line, add them.
+				This ensures that Wrap(Pad(*)) doesn't eliminate the
+				padding.
+			*/
+			if lineLength+spaceLength <= length {
+				line += space
+				lineLength += spaceLength
+			}
+
+			/* Add the current line as-is and clear everything */
+			result = append(result, line)
+			line = ""
+			lineLength = 0
+			space = ""
+			spaceLength = 0
+			word = ""
+			wordLength = 0
+		} else {
+			space += full
+			spaceLength += 1
+		}
+	}
+
+	/* Cleanup */
+	if wordLength > 0 {
+		line += space + word
+		lineLength += spaceLength + wordLength
+	}
+	finalLetter := ""
+	if len(expanded) > 0 {
+		finalLetter = expanded[len(expanded)-1][2]
+	}
+	if lineLength > 0 || finalLetter == "\n" {
+		result = append(result, line)
+	}
+
+	return strings.Join(result, "\n")
+}
+
+func DumbWrap(text string, width int) string {
+	expanded := expand(text)
+	result := ""
+	currentLineLength := 0
+
+	for _, match := range expanded {
+		full := match[0]
+		letter := match[2]
+
+		if letter == "\n" {
+			currentLineLength = 0
+			result += "\n"
+			continue
+		}
+
+		if currentLineLength == width {
+			currentLineLength = 0
+			result += "\n"
+		}
+
+		result += full
+		currentLineLength += 1
+	}
+	return result
+}
+
+/*
+	Limits `text` to the given `height` and `width`, adding an
+	ellipsis to the end and omitting trailing whitespace-only lines
+*/
+// TODO: this function could be optimized into just one loop
+func Snip(text string, width, height int, ellipsis string) string {
+	snipped := []string{}
+
+	/* This split is fine because newlines are
+	   guaranteed to not be wrapped in ansi codes */
+	lines := strings.Split(text, "\n")
+
+	requiresEllipsis := false
+
+	if len(lines) < height {
+		height = len(lines)
+	} else {
+		requiresEllipsis = true
+	}
+
+	for i := height - 1; i >= 0; i -= 1 {
+		line := expand(lines[i])
+		if len(snipped) == 0 {
+			if lineIsOnlyWhitespace(line) {
+				requiresEllipsis = true
+				continue
+			}
+
+			/* Remove last character to make way for ellipsis */
+			if len(line) == width {
+				line = line[:len(line)-1]
+			}
+		}
+
+		snipped = append([]string{collapse(line)}, snipped...)
+	}
+
+	output := strings.Join(snipped, "\n")
+
+	if requiresEllipsis {
+		output += ellipsis
+	}
+
+	return output
+}
+
+func lineIsOnlyWhitespace(expanded [][]string) bool {
+	for _, match := range expanded {
+		if !unicode.IsSpace([]rune(match[2])[0]) {
+			return false
+		}
+	}
+
+	return true
+}
+
+func Height(text string) uint {
+	return uint(strings.Count(text, "\n")) + 1
+}
+
+func CenterVertically(prefix, centered, suffix string, height uint) string {
+	prefixHeight, centeredHeight, suffixHeight := Height(prefix), Height(centered), Height(suffix)
+	if height < centeredHeight {
+		panic("screen is too small to vertically center text within it")
+	}
+	totalBufferSize := height - centeredHeight
+	topBufferSize := totalBufferSize / 2
+	bottomBufferSize := topBufferSize + totalBufferSize%2
+
+	if topBufferSize > prefixHeight {
+		prefix = strings.Repeat("\n", int(topBufferSize-prefixHeight)) + prefix
+	} else if topBufferSize < prefixHeight {
+		prefix = strings.Join(strings.Split(prefix, "\n")[prefixHeight-topBufferSize:], "\n")
+	}
+
+	if bottomBufferSize > suffixHeight {
+		suffix += strings.Repeat("\n", int(bottomBufferSize-suffixHeight))
+	} else if bottomBufferSize < suffixHeight {
+		suffix = strings.Join(strings.Split(suffix, "\n")[:bottomBufferSize], "\n")
+	}
+
+	return prefix + "\n" + centered + "\n" + suffix
+}
+
+/*
+	TODO:
+		add `Scrub` function that removes all ANSI codes from text
+		(this will be used when people redirect output to file)
+
+		add `Squash` function that converts newlines to spaces
+		(this will be used to prevent newlines from appearing
+		in things like names and titles), and removes control
+		characters
+
+		add `StrictWrap` function that wraps not based on whitespace
+		but strictly on length (this will be used for code blocks)
+
+		move `RemoveControlCharacters` from render to here
+*/

+ 265 - 265
ansi/ansi_test.go

@@ -1,265 +1,265 @@
-package ansi
-
-import (
-	"testing"
-	"mimicry/util"
-	"fmt"
-)
-
-func TestWrap(t *testing.T) {
-	// These test were pulled and modified from:
-	// https://github.com/muesli/reflow/blob/d4603be2c4a9017b4cf38856841116ffe0f04c59/wordwrap/wordwrap_test.go
-	tests := []struct {
-		Input        string
-		Expected     string
-		Limit        int
-	}{
-		// Nothing to wrap here, should pass through:
-		{
-			"foo",
-			"foo",
-			4,
-		},
-		// Snap words
-		{
-			"foobarfoo",
-			"foob\narfo\no",
-			4,
-		},
-		// Lines are broken at whitespace:
-		{
-			"foo bar foo",
-			"foo\nbar\nfoo",
-			4,
-		},
-		// Space buffer needs to be emptied before breakpoints:
-		{
-			"foo --bar",
-			"foo --bar",
-			9,
-		},
-		// Lines are broken at whitespace, and long words break as well
-		{
-			"foo bars foobars",
-			"foo\nbars\nfoob\nars",
-			4,
-		},
-		// A word that would run beyond the limit is wrapped:
-		{
-			"foo bar",
-			"foo\nbar",
-			5,
-		},
-		// Whitespace prefixing an explicit line break remains:
-		{
-			"foo\nb  a\n bar",
-			"foo\nb  a\n bar",
-			4,
-		},
-		// Trailing whitespace is removed if it doesn't fit the width.
-		// Runs of whitespace on which a line is broken are removed:
-		{
-			"foo    \nb   ar   ",
-			"foo\nb\nar",
-			4,
-		},
-		// An explicit line break at the end of the input is preserved:
-		{
-			"foo bar foo\n",
-			"foo\nbar\nfoo\n",
-			4,
-		},
-		// Explicit break are always preserved:
-		{
-			"\nfoo bar\n\n\nfoo\n",
-			"\nfoo\nbar\n\n\nfoo\n",
-			4,
-		},
-		// Complete example:
-		{
-			" This is a list: \n\n * foo\n * bar\n\n\n * foo  \nbar    ",
-			" This\nis a\nlist: \n\n * foo\n * bar\n\n\n * foo\nbar",
-			6,
-		},
-		// ANSI sequence codes don't affect length calculation:
-		{
-			"\x1B[38;2;249;38;114mfoo\x1B[0m\x1B[38;2;248;248;242m \x1B[0m\x1B[38;2;230;219;116mbar\x1B[0m",
-			"\x1B[38;2;249;38;114mfoo\x1B[0m\x1B[38;2;248;248;242m \x1B[0m\x1B[38;2;230;219;116mbar\x1B[0m",
-			7,
-		},
-		// ANSI control codes don't get wrapped:
-		{
-			"\x1B[38;2;249;38;114m(\x1B[0m\x1B[38;2;248;248;242mjust another test\x1B[38;2;249;38;114m)\x1B[0m",
-			"\x1B[38;2;249;38;114m(\x1B[0m\x1B[38;2;248;248;242mju\nst\nano\nthe\nr\ntes\nt\x1B[38;2;249;38;114m)\x1B[0m",
-			3,
-		},
-		// Many, many newlines shouldn't collapse:
-		{
-			"multi-space   \n\n\n\n\n far down",
-			"multi-sp\nace   \n\n\n\n\n far\ndown",
-			8,
-		},
-	}
-
-	for _, test := range tests {
-		output := Wrap(test.Input, test.Limit)
-		util.AssertEqual(test.Expected, output, t)
-
-		// Test that `Wrap` is idempotent
-		identical := Wrap(test.Expected, test.Limit)
-		util.AssertEqual(test.Expected, identical, t)
-	}
-}
-
-func TestCodeBlock(t *testing.T) {
-	input := "Soft-wrapped code block used to test everything"
-	wrapped := Wrap(input, 6)
-	padded := Pad(wrapped, 6)
-	indented := Indent(padded, "  ", true)
-	expected := `  Soft-w
-  rapped
-  code  
-  block 
-  used  
-  to    
-  test  
-  everyt
-  hing  `
-	util.AssertEqual(expected, indented, t)
-
-	fmt.Println("This should look like a nice, indented code block:")
-	styled := Indent(Apply(padded, "48;2;75;75;75"), "  ", true)
-	fmt.Println(styled)
-}
-
-func TestSnip(t *testing.T) {
-	// These test were pulled and modified from:
-	// https://github.com/muesli/reflow/blob/d4603be2c4a9017b4cf38856841116ffe0f04c59/wordwrap/wordwrap_test.go
-	tests := []struct {
-		Input string
-		Expected string
-		Height int
-		Width int
-	}{
-		// Restrict lines down:
-		{
-			"one\n\nthree\nfour",
-			"one\n\nthree…",
-			3,
-			25,
-		},
-		// Don't restrict lines when not necessary:
-		{
-			"one\n\nthree\nfour",
-			"one\n\nthree\nfour",
-			5,
-			25,
-		},
-		// Remove last character to insert ellipsis:
-		{
-			"one\ntwo\nthree\nfour",
-			"one\ntwo\nthre…",
-			3,
-			5,
-		},
-		// Omit trailing whitespace only lines:
-		{
-			"one\n\n \nfour",
-			"one…",
-			3,
-			25,
-		},
-		// Omit trailing whitespace and last character for ellipsis:
-		{
-			"one\n\n \nfour",
-			"on…",
-			3,
-			3,
-		},
-	}
-
-	for _, test := range tests {
-		output := Snip(test.Input, test.Width, test.Height, "…")
-		util.AssertEqual(test.Expected, output, t)
-	}	
-}
-
-func TestCenterVertically(t *testing.T) {
-	tests := []struct {
-		prefix string
-		centered string
-		suffix string
-		height uint
-		output string
-	}{
-		// normal case
-		{
-			"p1\np2",
-			"c1\nc2",
-			"s1\ns2",
-			6,
-			"p1\np2\nc1\nc2\ns1\ns2",
-		},
-
-		// offset center with even height
-		{
-			"p1",
-			"c1",
-			"s1\ns2",
-			4,
-			"p1\nc1\ns1\ns2",
-		},
-
-		// offset center with odd height
-		{
-			"p1",
-			"c1\nc2",
-			"s1\ns2",
-			5,
-			"p1\nc1\nc2\ns1\ns2",
-		},
-
-		// trimmed top
-		{
-			"p1\np2",
-			"c1\nc2",
-			"s1",
-			4,
-			"p2\nc1\nc2\ns1",
-		},
-
-		// buffered top (with offset)
-		{
-			"p1",
-			"c1",
-			"s1\ns2\ns3",
-			6,
-			"\np1\nc1\ns1\ns2\ns3",
-		},
-
-		// trimmed bottom
-		{
-			"p1",
-			"c1",
-			"s1\ns2",
-			3,
-			"p1\nc1\ns1",
-		},
-
-		// buffered bottom
-		{
-			"p1",
-			"c1",
-			"",
-			3,
-			"p1\nc1\n",
-		},
-	}
-
-	for i, test := range tests {
-		actual := CenterVertically(test.prefix, test.centered, test.suffix, test.height)
-		if test.output != actual {
-			t.Fatalf("Expected %v but received %v for test %v", test.output, actual, i)
-		}
-	}
-}
+package ansi
+
+import (
+	"fmt"
+	"mimicry/util"
+	"testing"
+)
+
+func TestWrap(t *testing.T) {
+	// These test were pulled and modified from:
+	// https://github.com/muesli/reflow/blob/d4603be2c4a9017b4cf38856841116ffe0f04c59/wordwrap/wordwrap_test.go
+	tests := []struct {
+		Input    string
+		Expected string
+		Limit    int
+	}{
+		// Nothing to wrap here, should pass through:
+		{
+			"foo",
+			"foo",
+			4,
+		},
+		// Snap words
+		{
+			"foobarfoo",
+			"foob\narfo\no",
+			4,
+		},
+		// Lines are broken at whitespace:
+		{
+			"foo bar foo",
+			"foo\nbar\nfoo",
+			4,
+		},
+		// Space buffer needs to be emptied before breakpoints:
+		{
+			"foo --bar",
+			"foo --bar",
+			9,
+		},
+		// Lines are broken at whitespace, and long words break as well
+		{
+			"foo bars foobars",
+			"foo\nbars\nfoob\nars",
+			4,
+		},
+		// A word that would run beyond the limit is wrapped:
+		{
+			"foo bar",
+			"foo\nbar",
+			5,
+		},
+		// Whitespace prefixing an explicit line break remains:
+		{
+			"foo\nb  a\n bar",
+			"foo\nb  a\n bar",
+			4,
+		},
+		// Trailing whitespace is removed if it doesn't fit the width.
+		// Runs of whitespace on which a line is broken are removed:
+		{
+			"foo    \nb   ar   ",
+			"foo\nb\nar",
+			4,
+		},
+		// An explicit line break at the end of the input is preserved:
+		{
+			"foo bar foo\n",
+			"foo\nbar\nfoo\n",
+			4,
+		},
+		// Explicit break are always preserved:
+		{
+			"\nfoo bar\n\n\nfoo\n",
+			"\nfoo\nbar\n\n\nfoo\n",
+			4,
+		},
+		// Complete example:
+		{
+			" This is a list: \n\n * foo\n * bar\n\n\n * foo  \nbar    ",
+			" This\nis a\nlist: \n\n * foo\n * bar\n\n\n * foo\nbar",
+			6,
+		},
+		// ANSI sequence codes don't affect length calculation:
+		{
+			"\x1B[38;2;249;38;114mfoo\x1B[0m\x1B[38;2;248;248;242m \x1B[0m\x1B[38;2;230;219;116mbar\x1B[0m",
+			"\x1B[38;2;249;38;114mfoo\x1B[0m\x1B[38;2;248;248;242m \x1B[0m\x1B[38;2;230;219;116mbar\x1B[0m",
+			7,
+		},
+		// ANSI control codes don't get wrapped:
+		{
+			"\x1B[38;2;249;38;114m(\x1B[0m\x1B[38;2;248;248;242mjust another test\x1B[38;2;249;38;114m)\x1B[0m",
+			"\x1B[38;2;249;38;114m(\x1B[0m\x1B[38;2;248;248;242mju\nst\nano\nthe\nr\ntes\nt\x1B[38;2;249;38;114m)\x1B[0m",
+			3,
+		},
+		// Many, many newlines shouldn't collapse:
+		{
+			"multi-space   \n\n\n\n\n far down",
+			"multi-sp\nace   \n\n\n\n\n far\ndown",
+			8,
+		},
+	}
+
+	for _, test := range tests {
+		output := Wrap(test.Input, test.Limit)
+		util.AssertEqual(test.Expected, output, t)
+
+		// Test that `Wrap` is idempotent
+		identical := Wrap(test.Expected, test.Limit)
+		util.AssertEqual(test.Expected, identical, t)
+	}
+}
+
+func TestCodeBlock(t *testing.T) {
+	input := "Soft-wrapped code block used to test everything"
+	wrapped := Wrap(input, 6)
+	padded := Pad(wrapped, 6)
+	indented := Indent(padded, "  ", true)
+	expected := `  Soft-w
+  rapped
+  code  
+  block 
+  used  
+  to    
+  test  
+  everyt
+  hing  `
+	util.AssertEqual(expected, indented, t)
+
+	fmt.Println("This should look like a nice, indented code block:")
+	styled := Indent(Apply(padded, "48;2;75;75;75"), "  ", true)
+	fmt.Println(styled)
+}
+
+func TestSnip(t *testing.T) {
+	// These test were pulled and modified from:
+	// https://github.com/muesli/reflow/blob/d4603be2c4a9017b4cf38856841116ffe0f04c59/wordwrap/wordwrap_test.go
+	tests := []struct {
+		Input    string
+		Expected string
+		Height   int
+		Width    int
+	}{
+		// Restrict lines down:
+		{
+			"one\n\nthree\nfour",
+			"one\n\nthree…",
+			3,
+			25,
+		},
+		// Don't restrict lines when not necessary:
+		{
+			"one\n\nthree\nfour",
+			"one\n\nthree\nfour",
+			5,
+			25,
+		},
+		// Remove last character to insert ellipsis:
+		{
+			"one\ntwo\nthree\nfour",
+			"one\ntwo\nthre…",
+			3,
+			5,
+		},
+		// Omit trailing whitespace only lines:
+		{
+			"one\n\n \nfour",
+			"one…",
+			3,
+			25,
+		},
+		// Omit trailing whitespace and last character for ellipsis:
+		{
+			"one\n\n \nfour",
+			"on…",
+			3,
+			3,
+		},
+	}
+
+	for _, test := range tests {
+		output := Snip(test.Input, test.Width, test.Height, "…")
+		util.AssertEqual(test.Expected, output, t)
+	}
+}
+
+func TestCenterVertically(t *testing.T) {
+	tests := []struct {
+		prefix   string
+		centered string
+		suffix   string
+		height   uint
+		output   string
+	}{
+		// normal case
+		{
+			"p1\np2",
+			"c1\nc2",
+			"s1\ns2",
+			6,
+			"p1\np2\nc1\nc2\ns1\ns2",
+		},
+
+		// offset center with even height
+		{
+			"p1",
+			"c1",
+			"s1\ns2",
+			4,
+			"p1\nc1\ns1\ns2",
+		},
+
+		// offset center with odd height
+		{
+			"p1",
+			"c1\nc2",
+			"s1\ns2",
+			5,
+			"p1\nc1\nc2\ns1\ns2",
+		},
+
+		// trimmed top
+		{
+			"p1\np2",
+			"c1\nc2",
+			"s1",
+			4,
+			"p2\nc1\nc2\ns1",
+		},
+
+		// buffered top (with offset)
+		{
+			"p1",
+			"c1",
+			"s1\ns2\ns3",
+			6,
+			"\np1\nc1\ns1\ns2\ns3",
+		},
+
+		// trimmed bottom
+		{
+			"p1",
+			"c1",
+			"s1\ns2",
+			3,
+			"p1\nc1\ns1",
+		},
+
+		// buffered bottom
+		{
+			"p1",
+			"c1",
+			"",
+			3,
+			"p1\nc1\n",
+		},
+	}
+
+	for i, test := range tests {
+		actual := CenterVertically(test.prefix, test.centered, test.suffix, test.height)
+		if test.output != actual {
+			t.Fatalf("Expected %v but received %v for test %v", test.output, actual, i)
+		}
+	}
+}

+ 29 - 22
client/client.go

@@ -1,15 +1,15 @@
 package client
 
 import (
-	"errors"
-	"net/url"
-	"strings"
-	"mimicry/jtp"
-	"os"
 	"encoding/json"
-	"mimicry/object"
+	"errors"
 	"fmt"
 	"golang.org/x/sync/singleflight"
+	"mimicry/jtp"
+	"mimicry/object"
+	"net/url"
+	"os"
+	"strings"
 )
 
 const MAX_REDIRECTS = 20
@@ -23,7 +23,9 @@ func FetchUnknown(input any, source *url.URL) (object.Object, *url.URL, error) {
 			return nil, nil, err
 		}
 		obj, source, err = FetchURL(url)
-		if err != nil { return nil, nil, err }
+		if err != nil {
+			return nil, nil, err
+		}
 	case map[string]any:
 		obj = object.Object(narrowed)
 	default:
@@ -41,10 +43,14 @@ func FetchUnknown(input any, source *url.URL) (object.Object, *url.URL, error) {
 	if id != nil {
 		if source == nil {
 			obj, source, err = FetchURL(id)
-			if err != nil { return nil, nil, err }
+			if err != nil {
+				return nil, nil, err
+			}
 		} else if (source.Host != id.Host) || len(obj) <= 2 {
 			obj, source, err = FetchURL(id)
-			if err != nil { return nil, nil, err }
+			if err != nil {
+				return nil, nil, err
+			}
 		}
 	}
 
@@ -55,10 +61,11 @@ func FetchUnknown(input any, source *url.URL) (object.Object, *url.URL, error) {
 }
 
 var group singleflight.Group
+
 type bundle struct {
-	item map[string]any
+	item   map[string]any
 	source *url.URL
-	err error
+	err    error
 }
 
 /* A map of mutexes is used to ensure no two requests are made simultaneously.
@@ -68,11 +75,11 @@ type bundle struct {
 func FetchURL(uri *url.URL) (object.Object, *url.URL, error) {
 	uriString := uri.String()
 	b, _, _ := group.Do(uriString, func() (any, error) {
-		json, source, err := 
+		json, source, err :=
 			jtp.Get(
 				uri,
-				`application/activity+json,` +
-				`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`,
+				`application/activity+json,`+
+					`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`,
 				[]string{
 					"application/activity+json",
 					"application/ld+json",
@@ -80,10 +87,10 @@ func FetchURL(uri *url.URL) (object.Object, *url.URL, error) {
 				},
 				MAX_REDIRECTS,
 			)
-		return bundle {
-			item: json,
+		return bundle{
+			item:   json,
 			source: source,
-			err: err,
+			err:    err,
 		}, nil
 	})
 	/* By this point the result has been cached in the LRU cache,
@@ -93,8 +100,8 @@ func FetchURL(uri *url.URL) (object.Object, *url.URL, error) {
 }
 
 /*
-	converts a webfinger identifier to a url
-	see: https://datatracker.ietf.org/doc/html/rfc7033
+converts a webfinger identifier to a url
+see: https://datatracker.ietf.org/doc/html/rfc7033
 */
 func ResolveWebfinger(username string) (string, error) {
 	if len(username) == 0 || username[0] != '@' {
@@ -112,9 +119,9 @@ func ResolveWebfinger(username string) (string, error) {
 
 	link := &url.URL{
 		Scheme: "https",
-		Host: domain,
-		Path: "/.well-known/webfinger",
-		RawQuery: (url.Values {
+		Host:   domain,
+		Path:   "/.well-known/webfinger",
+		RawQuery: (url.Values{
 			"resource": []string{"acct:" + account + "@" + domain},
 		}).Encode(),
 	}

+ 61 - 61
config/config.go

@@ -1,61 +1,61 @@
-package config
-
-import (
-	"fmt"
-	"errors"
-	"os"
-	"github.com/BurntSushi/toml"
-)
-
-type Config struct {
-	Context int
-	Timeout int
-	Feeds feeds
-	Algos algos
-}
-
-type feeds = map[string][]string
-type algos = map[string]struct {
-	Server string
-	Query string
-}
-
-func Parse() (*Config, error) {
-	config := &Config {
-		Context: 5,
-		Timeout: 10,
-		Feeds: feeds{},
-		Algos: algos{},
-	}
-
-	location := location()
-	if location == "" {
-		return config, nil
-	}
-
-	metadata, err := toml.DecodeFile(location, config)
-	if errors.Is(err, os.ErrNotExist) {
-		return config, nil
-	}
-	if err != nil {
-		return nil, err
-	}
-
-	if undecoded := metadata.Undecoded(); len(undecoded) != 0 {
-		return nil, fmt.Errorf("config file %s contained unexpected keys: %v", location, undecoded)
-	}
-
-	return config, nil
-}
-
-func location() string {
-	if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
-		return xdg + "/mimicry/config.toml"
-	}
-
-	if home := os.Getenv("HOME"); home != "" {
-		return home + "/.config/mimicry/config.toml"
-	}
-
-	return ""
-}
+package config
+
+import (
+	"errors"
+	"fmt"
+	"github.com/BurntSushi/toml"
+	"os"
+)
+
+type Config struct {
+	Context int
+	Timeout int
+	Feeds   feeds
+	Algos   algos
+}
+
+type feeds = map[string][]string
+type algos = map[string]struct {
+	Server string
+	Query  string
+}
+
+func Parse() (*Config, error) {
+	config := &Config{
+		Context: 5,
+		Timeout: 10,
+		Feeds:   feeds{},
+		Algos:   algos{},
+	}
+
+	location := location()
+	if location == "" {
+		return config, nil
+	}
+
+	metadata, err := toml.DecodeFile(location, config)
+	if errors.Is(err, os.ErrNotExist) {
+		return config, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	if undecoded := metadata.Undecoded(); len(undecoded) != 0 {
+		return nil, fmt.Errorf("config file %s contained unexpected keys: %v", location, undecoded)
+	}
+
+	return config, nil
+}
+
+func location() string {
+	if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
+		return xdg + "/mimicry/config.toml"
+	}
+
+	if home := os.Getenv("HOME"); home != "" {
+		return home + "/.config/mimicry/config.toml"
+	}
+
+	return ""
+}

+ 57 - 57
feed/feed.go

@@ -1,57 +1,57 @@
-package feed
-
-import (
-	"mimicry/pub"
-	"fmt"
-)
-
-type Feed struct {
-	feed map[int]pub.Tangible
-	upperBound int
-	lowerBound int
-}
-
-func Create(input pub.Tangible) *Feed {
-	return &Feed {
-		feed: map[int]pub.Tangible {
-			0: input,
-		},
-		upperBound: 0,
-		lowerBound: 0,
-	}
-}
-
-func CreateAndAppend(input []pub.Tangible) *Feed {
-	f := &Feed{
-		feed: map[int]pub.Tangible{},
-	}
-	f.Append(input)
-	f.lowerBound = 1
-	return f
-}
-
-func (f *Feed) Append(input []pub.Tangible) {
-	for i, element := range input {
-		f.feed[f.upperBound+i+1] = element
-	}
-	f.upperBound += len(input)
-}
-
-func (f *Feed) Prepend(input []pub.Tangible) {
-	for i, element := range input {
-		f.feed[f.lowerBound-i-1] = element
-	}
-	f.lowerBound -= len(input)
-}
-
-func (f *Feed) Get(index int) pub.Tangible {
-	if index > f.upperBound || index < f.lowerBound {
-		panic(fmt.Sprintf("indexing feed at %d whereas bounds are %d and %d", index, f.lowerBound, f.upperBound))
-	}
-
-	return f.feed[index]
-}
-
-func (f *Feed) Contains(index int) bool {
-	return index <= f.upperBound && index >= f.lowerBound
-}
+package feed
+
+import (
+	"fmt"
+	"mimicry/pub"
+)
+
+type Feed struct {
+	feed       map[int]pub.Tangible
+	upperBound int
+	lowerBound int
+}
+
+func Create(input pub.Tangible) *Feed {
+	return &Feed{
+		feed: map[int]pub.Tangible{
+			0: input,
+		},
+		upperBound: 0,
+		lowerBound: 0,
+	}
+}
+
+func CreateAndAppend(input []pub.Tangible) *Feed {
+	f := &Feed{
+		feed: map[int]pub.Tangible{},
+	}
+	f.Append(input)
+	f.lowerBound = 1
+	return f
+}
+
+func (f *Feed) Append(input []pub.Tangible) {
+	for i, element := range input {
+		f.feed[f.upperBound+i+1] = element
+	}
+	f.upperBound += len(input)
+}
+
+func (f *Feed) Prepend(input []pub.Tangible) {
+	for i, element := range input {
+		f.feed[f.lowerBound-i-1] = element
+	}
+	f.lowerBound -= len(input)
+}
+
+func (f *Feed) Get(index int) pub.Tangible {
+	if index > f.upperBound || index < f.lowerBound {
+		panic(fmt.Sprintf("indexing feed at %d whereas bounds are %d and %d", index, f.lowerBound, f.upperBound))
+	}
+
+	return f.feed[index]
+}
+
+func (f *Feed) Contains(index int) bool {
+	return index <= f.upperBound && index >= f.lowerBound
+}

+ 65 - 65
feed/feed_test.go

@@ -1,65 +1,65 @@
-package feed
-
-import (
-	"testing"
-	"mimicry/pub"
-	"mimicry/object"
-)
-
-var post1, _ = pub.NewPostFromObject(object.Object {
-	"type": "Note",
-	"content": "Hello!",
-}, nil)
-
-var post2, _ = pub.NewPostFromObject(object.Object {
-	"type": "Video",
-	"content": "Goodbye!",
-}, nil)
-
-func TestCreate(t *testing.T) {
-	feed := Create(post1)
-	shouldBePost1 := feed.Get(0)
-	if shouldBePost1 != post1 {
-		t.Fatalf("Center Posts differ after Create, is %#v but should be %#v", shouldBePost1, post1)
-	}
-}
-
-func TestCreateCreateAndAppend(t *testing.T) {
-	feed := CreateAndAppend([]pub.Tangible{post1})
-	shouldBePost1 := feed.Get(1)
-	if shouldBePost1 != post1 {
-		t.Fatalf("Posts differed after create centerless, is %#v but should be %#v", shouldBePost1, post1)
-	}
-	defer func() {
-        if recover() == nil {
-            t.Fatalf("After create centerless, Get(0) should have panicked but did not")
-        }
-    }()
-	feed.Get(0)
-}
-
-func TestAppend(t *testing.T) {
-	feed := Create(post1)
-	feed.Append([]pub.Tangible{post2})
-	shouldBePost1 := feed.Get(0)
-	shouldBePost2 := feed.Get(1)
-	if shouldBePost1 != post1 {
-		t.Fatalf("Center Posts differ after Append, is %#v but should be %#v", shouldBePost1, post1)
-	}
-	if shouldBePost2 != post2 {
-		t.Fatalf("Appended posts differ, is %#v but should be %#v", shouldBePost2, post2)
-	}
-}
-
-func TestPrepend(t *testing.T) {
-	feed := Create(post1)
-	feed.Prepend([]pub.Tangible{post2})
-	shouldBePost1 := feed.Get(0)
-	shouldBePost2 := feed.Get(-1)
-	if shouldBePost1 != post1 {
-		t.Fatalf("Center Posts differ after Prepend, is %#v but should be %#v", shouldBePost1, post1)
-	}
-	if shouldBePost2 != post2 {
-		t.Fatalf("Prepended posts differ, is %#v but should be %#v", shouldBePost2, post2)
-	}
-}
+package feed
+
+import (
+	"mimicry/object"
+	"mimicry/pub"
+	"testing"
+)
+
+var post1, _ = pub.NewPostFromObject(object.Object{
+	"type":    "Note",
+	"content": "Hello!",
+}, nil)
+
+var post2, _ = pub.NewPostFromObject(object.Object{
+	"type":    "Video",
+	"content": "Goodbye!",
+}, nil)
+
+func TestCreate(t *testing.T) {
+	feed := Create(post1)
+	shouldBePost1 := feed.Get(0)
+	if shouldBePost1 != post1 {
+		t.Fatalf("Center Posts differ after Create, is %#v but should be %#v", shouldBePost1, post1)
+	}
+}
+
+func TestCreateCreateAndAppend(t *testing.T) {
+	feed := CreateAndAppend([]pub.Tangible{post1})
+	shouldBePost1 := feed.Get(1)
+	if shouldBePost1 != post1 {
+		t.Fatalf("Posts differed after create centerless, is %#v but should be %#v", shouldBePost1, post1)
+	}
+	defer func() {
+		if recover() == nil {
+			t.Fatalf("After create centerless, Get(0) should have panicked but did not")
+		}
+	}()
+	feed.Get(0)
+}
+
+func TestAppend(t *testing.T) {
+	feed := Create(post1)
+	feed.Append([]pub.Tangible{post2})
+	shouldBePost1 := feed.Get(0)
+	shouldBePost2 := feed.Get(1)
+	if shouldBePost1 != post1 {
+		t.Fatalf("Center Posts differ after Append, is %#v but should be %#v", shouldBePost1, post1)
+	}
+	if shouldBePost2 != post2 {
+		t.Fatalf("Appended posts differ, is %#v but should be %#v", shouldBePost2, post2)
+	}
+}
+
+func TestPrepend(t *testing.T) {
+	feed := Create(post1)
+	feed.Prepend([]pub.Tangible{post2})
+	shouldBePost1 := feed.Get(0)
+	shouldBePost2 := feed.Get(-1)
+	if shouldBePost1 != post1 {
+		t.Fatalf("Center Posts differ after Prepend, is %#v but should be %#v", shouldBePost1, post1)
+	}
+	if shouldBePost2 != post2 {
+		t.Fatalf("Prepended posts differ, is %#v but should be %#v", shouldBePost2, post2)
+	}
+}

+ 64 - 64
gemtext/gemtext.go

@@ -1,64 +1,64 @@
-package gemtext
-
-import (
-	"mimicry/style"
-	"strings"
-	"regexp"
-)
-
-/*
-	Specification:
-	https://gemini.circumlunar.space/docs/specification.html
-*/
-
-func Render(text string, width int) (string, error) {
-	lines := strings.Split(text, "\n")
-	result := ""
-	preformattedMode := false
-	preformattedBuffer := ""
-	for _, line := range lines {
-		if strings.HasPrefix(line, "```") {
-			if preformattedMode {
-				result += style.CodeBlock(strings.TrimSuffix(preformattedBuffer, "\n")) + "\n"
-				preformattedBuffer = ""
-				preformattedMode = false
-			} else {
-				preformattedMode = true
-			}
-			continue
-		}
-
-		if preformattedMode {
-			preformattedBuffer += line + "\n"
-			continue
-		}
-
-		if match := regexp.MustCompile(`^=>[ \t]*(.*?)(?:[ \t]+(.*))?$`).FindStringSubmatch(line); len(match) == 3 {
-			url := match[1]
-			alt := match[2]
-			if alt == "" {
-				alt = url
-			}
-			result += style.LinkBlock(alt) + "\n"
-		} else if match := regexp.MustCompile(`^#[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
-			result += style.Header(match[1], 1) + "\n"
-		} else if match := regexp.MustCompile(`^##[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
-			result += style.Header(match[1], 2) + "\n"
-		} else if match := regexp.MustCompile(`^###[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
-			result += style.Header(match[1], 3) + "\n"
-		} else if match := regexp.MustCompile(`^\* (.*)$`).FindStringSubmatch(line); len(match) == 2 {
-			result += style.Bullet(match[1]) + "\n"
-		} else if match := regexp.MustCompile(`^> ?(.*)$`).FindStringSubmatch(line); len(match) == 2 {
-			result += style.QuoteBlock(match[1]) + "\n"
-		} else {
-			result += line + "\n"
-		}
-	}
-
-	// If trailing backticks are omitted, implicitly automatically add them
-	if preformattedMode {
-		result += style.CodeBlock(strings.TrimSuffix(preformattedBuffer, "\n")) + "\n"
-	}
-
-	return strings.TrimSuffix(result, "\n"), nil
-}
+package gemtext
+
+import (
+	"mimicry/style"
+	"regexp"
+	"strings"
+)
+
+/*
+	Specification:
+	https://gemini.circumlunar.space/docs/specification.html
+*/
+
+func Render(text string, width int) (string, error) {
+	lines := strings.Split(text, "\n")
+	result := ""
+	preformattedMode := false
+	preformattedBuffer := ""
+	for _, line := range lines {
+		if strings.HasPrefix(line, "```") {
+			if preformattedMode {
+				result += style.CodeBlock(strings.TrimSuffix(preformattedBuffer, "\n")) + "\n"
+				preformattedBuffer = ""
+				preformattedMode = false
+			} else {
+				preformattedMode = true
+			}
+			continue
+		}
+
+		if preformattedMode {
+			preformattedBuffer += line + "\n"
+			continue
+		}
+
+		if match := regexp.MustCompile(`^=>[ \t]*(.*?)(?:[ \t]+(.*))?$`).FindStringSubmatch(line); len(match) == 3 {
+			url := match[1]
+			alt := match[2]
+			if alt == "" {
+				alt = url
+			}
+			result += style.LinkBlock(alt) + "\n"
+		} else if match := regexp.MustCompile(`^#[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
+			result += style.Header(match[1], 1) + "\n"
+		} else if match := regexp.MustCompile(`^##[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
+			result += style.Header(match[1], 2) + "\n"
+		} else if match := regexp.MustCompile(`^###[ \t]+(.*)$`).FindStringSubmatch(line); len(match) == 2 {
+			result += style.Header(match[1], 3) + "\n"
+		} else if match := regexp.MustCompile(`^\* (.*)$`).FindStringSubmatch(line); len(match) == 2 {
+			result += style.Bullet(match[1]) + "\n"
+		} else if match := regexp.MustCompile(`^> ?(.*)$`).FindStringSubmatch(line); len(match) == 2 {
+			result += style.QuoteBlock(match[1]) + "\n"
+		} else {
+			result += line + "\n"
+		}
+	}
+
+	// If trailing backticks are omitted, implicitly automatically add them
+	if preformattedMode {
+		result += style.CodeBlock(strings.TrimSuffix(preformattedBuffer, "\n")) + "\n"
+	}
+
+	return strings.TrimSuffix(result, "\n"), nil
+}

+ 38 - 38
gemtext/gemtext_test.go

@@ -1,38 +1,38 @@
-package gemtext
-
-import (
-	"testing"
-	"mimicry/style"
-	"mimicry/util"
-)
-
-func TestBasic(t *testing.T) {
-	input := `> blockquote
-
-* bullet point
-
-# large header
-## smaller header
-### smallest header
-
-=> https://www.wikipedia.org/ Wikipedia is great!
-
-=>http://example.org/
-
-` + "```\ncode block\nhere\n```"
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-
-	expected := style.QuoteBlock("blockquote") + "\n\n" +
-		style.Bullet("bullet point") + "\n\n" +
-		style.Header("large header", 1) + "\n" +
-		style.Header("smaller header", 2) + "\n" +
-		style.Header("smallest header", 3) + "\n\n" +
-		style.LinkBlock("Wikipedia is great!") + "\n\n" +
-		style.LinkBlock("http://example.org/") + "\n\n" +
-		style.CodeBlock("code block\nhere")
-
-	util.AssertEqual(expected, output, t)
-}
+package gemtext
+
+import (
+	"mimicry/style"
+	"mimicry/util"
+	"testing"
+)
+
+func TestBasic(t *testing.T) {
+	input := `> blockquote
+
+* bullet point
+
+# large header
+## smaller header
+### smallest header
+
+=> https://www.wikipedia.org/ Wikipedia is great!
+
+=>http://example.org/
+
+` + "```\ncode block\nhere\n```"
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+
+	expected := style.QuoteBlock("blockquote") + "\n\n" +
+		style.Bullet("bullet point") + "\n\n" +
+		style.Header("large header", 1) + "\n" +
+		style.Header("smaller header", 2) + "\n" +
+		style.Header("smallest header", 3) + "\n\n" +
+		style.LinkBlock("Wikipedia is great!") + "\n\n" +
+		style.LinkBlock("http://example.org/") + "\n\n" +
+		style.CodeBlock("code block\nhere")
+
+	util.AssertEqual(expected, output, t)
+}

+ 278 - 272
hypertext/hypertext.go

@@ -1,272 +1,278 @@
-package hypertext
-
-import (
-	"golang.org/x/net/html"
-	"golang.org/x/net/html/atom"
-	"strings"
-	"regexp"
-	"mimicry/style"
-	"errors"
-	"mimicry/ansi"
-)
-
-// TODO: create a `bulletedList` function for all situations where
-// html-specific wrapping is needed. Put them at the bottom of the file
-// and note that that section is different. (This will include one for
-// headers)
-
-// TODO: blocks need to be trimmed on the inside and newlined on 
-// the outside
-
-/* Terminal codes and control characters should already be escaped
-   by this point */
-func Render(text string, width int) (string, error) {
-	nodes, err := html.ParseFragment(strings.NewReader(text), &html.Node{
-		Type: html.ElementNode,
-		Data: "body",
-		DataAtom: atom.Body,
-	})
-	if err != nil {
-		return "", err
-	}
-	rendered, err := renderList(nodes, width)
-	if err != nil {
-		return "", err
-	}
-
-	wrapped := ansi.Wrap(rendered, width)
-	return strings.Trim(wrapped, " \n"), nil
-}
-
-func renderList(nodes []*html.Node, width int) (string, error) {
-	output := ""
-	for _, current := range nodes {
-		result, err := renderNode(current, width, false)
-		if err != nil {
-			return "", err
-		}
-		output = mergeText(output, result)
-	}
-	return output, nil
-}
-
-/* 	Merges text according to the following rules:
-	1. Extract trailing whitespace from lhs and
-	   leading whitespace from rhs and concat them.
-	2. Append the two sides in the following way,
-	   depending on the extracted whitespace:
-	   	- If it is empty, append the sides
-		- Else, if it contains 0 newlines, append
-		  the sides with a single space between.
-		- Else, if it contains 1 newline, append
-		  the sides with a single newline between.
-		- Else, append the sides with 2 newlines
-		  between.
-*/
-func mergeText(lhs string, rhs string) string {
-	trimRight := regexp.MustCompile(`(?s)^(.*?)([ \n]*)$`)
-	lhsMatches := trimRight.FindStringSubmatch(lhs)
-	lhsTrimmed := lhsMatches[1]
-
-	trimLeft := regexp.MustCompile(`(?s)^([ \n]*)(.*)$`)
-	rhsMatches := trimLeft.FindStringSubmatch(rhs)
-	rhsTrimmed := rhsMatches[2]
-
-	whitespace := lhsMatches[2] + rhsMatches[1]
-
-	if whitespace == "" {
-		return lhsTrimmed + rhsTrimmed
-	}
-
-	switch strings.Count(whitespace, "\n") {
-	case 0: return lhsTrimmed + " " + rhsTrimmed
-	case 1: return lhsTrimmed + "\n" + rhsTrimmed
-	}
-
-	return lhsTrimmed + "\n\n" + rhsTrimmed
-}
-
-func renderNode(node *html.Node, width int, preserveWhitespace bool) (string, error) {
-	if node.Type == html.TextNode {
-		if !preserveWhitespace {
-			whitespace := regexp.MustCompile(`[ \t\n\r]+`)
-			return whitespace.ReplaceAllString(node.Data, " "), nil
-		}
-		return node.Data, nil
-	}
-
-	if node.Type != html.ElementNode {
-		return "", nil
-	}
-
-	content, err := renderChildren(node, width, preserveWhitespace)
-	if err != nil {
-		return "", err
-	}
-
-	switch node.Data {
-	case "a":
-		return style.Link(content), nil
-	case "s", "del":
-		return style.Strikethrough(content), nil
-	case "code":
-		return style.Code(content), nil
-	case "i", "em":
-		return style.Italic(content), nil
-	case "b", "strong":
-		return style.Bold(content), nil
-	case "u", "ins":
-		return style.Underline(content), nil
-	case "mark":
-		return style.Highlight(content), nil
-	case "span", "li", "small":
-		return content, nil
-	case "br":
-		return "\n", nil
-
-	case "p", "div":
-		return block(content), nil
-	case "pre":
-		content, err := renderChildren(node, width - 2, true)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width, true)
-		return block(style.CodeBlock(wrapped)), err
-	case "blockquote":
-		content, err := renderChildren(node, width - 1, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width - 1, preserveWhitespace)
-		// TODO: this text wrap is ugly
-		return block(style.QuoteBlock(strings.Trim(wrapped, " \n"))), nil
-	case "ul":
-		list, err := bulletedList(node, width, preserveWhitespace)
-		return list, err
-	// case "ul":
-	// 	return numberedList(node), nil
-
-	case "h1":
-		content, err := renderChildren(node, width - 2, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width - 2, preserveWhitespace)
-		return block(style.Header(wrapped, 1)), nil
-	case "h2":
-		content, err := renderChildren(node, width - 3, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width - 3, preserveWhitespace)
-		return block(style.Header(wrapped, 2)), nil
-	case "h3":
-		content, err := renderChildren(node, width - 4, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width - 4, preserveWhitespace)
-		return block(style.Header(wrapped, 3)), nil
-	case "h4":
-		content, err := renderChildren(node, width - 5, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width - 5, preserveWhitespace)
-		return block(style.Header(wrapped, 4)), nil
-	case "h5":
-		content, err := renderChildren(node, width - 6, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width - 6, preserveWhitespace)
-		return block(style.Header(wrapped, 5)), nil
-	case "h6":
-		content, err := renderChildren(node, width - 7, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(content, width - 7, preserveWhitespace)
-		return block(style.Header(wrapped, 6)), nil
-
-	case "hr":
-		return block(strings.Repeat("―", width)), nil
-	case "img", "video", "audio", "iframe":
-		text := getAttribute("alt", node.Attr)
-		if text == "" {
-			text = getAttribute("title", node.Attr)
-		}
-		if text == "" {
-			text = getAttribute("src", node.Attr)
-		}
-		if text == "" {
-			return "", errors.New(node.Data + " tag is missing both `alt` and `src` attributes")
-		}
-		wrapped := situationalWrap(text, width - 2, preserveWhitespace)
-		return block(style.LinkBlock(wrapped)), nil
-	}
-
-	return "", errors.New("Encountered unrecognized element " + node.Data)
-}
-
-func renderChildren(node *html.Node, width int, preserveWhitespace bool) (string, error) {
-	output := ""
-	for current := node.FirstChild; current != nil; current = current.NextSibling {
-		result, err := renderNode(current, width, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		output = mergeText(output, result)
-	}
-	return output, nil
-}
-
-func block(text string) string {
-	return "\n\n" + strings.Trim(text, " \n") + "\n\n"
-}
-
-func bulletedList(node *html.Node, width int, preserveWhitespace bool) (string, error) {
-	output := ""
-	for current := node.FirstChild; current != nil; current = current.NextSibling {
-		if current.Type != html.ElementNode {
-			continue
-		}
-
-		if current.Data != "li" {
-			continue
-		}
-
-		result, err := renderNode(current, width - 2, preserveWhitespace)
-		if err != nil {
-			return "", err
-		}
-		wrapped := situationalWrap(result, width - 2, preserveWhitespace)
-		output += "\n" + style.Bullet(wrapped)
-	}
-
-	if node.Parent == nil {
-		return block(output), nil
-	} else if node.Parent.Data == "li" {
-		return output, nil
-	} else {
-		return block(output), nil
-	}
-}
-
-func getAttribute(name string, attributes []html.Attribute) string {
-	for _, attribute := range attributes {
-		if attribute.Key == name {
-			return attribute.Val
-		}
-	}
-	return ""
-}
-
-func situationalWrap(text string, width int, preserveWhitespace bool) string {
-	if preserveWhitespace {
-		return ansi.DumbWrap(text, width)
-	}
-
-	return ansi.Wrap(text, width)
-}
+package hypertext
+
+import (
+	"errors"
+	"golang.org/x/net/html"
+	"golang.org/x/net/html/atom"
+	"mimicry/ansi"
+	"mimicry/style"
+	"regexp"
+	"strings"
+)
+
+// TODO: create a `bulletedList` function for all situations where
+// html-specific wrapping is needed. Put them at the bottom of the file
+// and note that that section is different. (This will include one for
+// headers)
+
+// TODO: blocks need to be trimmed on the inside and newlined on
+// the outside
+
+/*
+Terminal codes and control characters should already be escaped
+
+	by this point
+*/
+func Render(text string, width int) (string, error) {
+	nodes, err := html.ParseFragment(strings.NewReader(text), &html.Node{
+		Type:     html.ElementNode,
+		Data:     "body",
+		DataAtom: atom.Body,
+	})
+	if err != nil {
+		return "", err
+	}
+	rendered, err := renderList(nodes, width)
+	if err != nil {
+		return "", err
+	}
+
+	wrapped := ansi.Wrap(rendered, width)
+	return strings.Trim(wrapped, " \n"), nil
+}
+
+func renderList(nodes []*html.Node, width int) (string, error) {
+	output := ""
+	for _, current := range nodes {
+		result, err := renderNode(current, width, false)
+		if err != nil {
+			return "", err
+		}
+		output = mergeText(output, result)
+	}
+	return output, nil
+}
+
+/*
+		Merges text according to the following rules:
+	 1. Extract trailing whitespace from lhs and
+	    leading whitespace from rhs and concat them.
+	 2. Append the two sides in the following way,
+	    depending on the extracted whitespace:
+	    - If it is empty, append the sides
+	    - Else, if it contains 0 newlines, append
+	    the sides with a single space between.
+	    - Else, if it contains 1 newline, append
+	    the sides with a single newline between.
+	    - Else, append the sides with 2 newlines
+	    between.
+*/
+func mergeText(lhs string, rhs string) string {
+	trimRight := regexp.MustCompile(`(?s)^(.*?)([ \n]*)$`)
+	lhsMatches := trimRight.FindStringSubmatch(lhs)
+	lhsTrimmed := lhsMatches[1]
+
+	trimLeft := regexp.MustCompile(`(?s)^([ \n]*)(.*)$`)
+	rhsMatches := trimLeft.FindStringSubmatch(rhs)
+	rhsTrimmed := rhsMatches[2]
+
+	whitespace := lhsMatches[2] + rhsMatches[1]
+
+	if whitespace == "" {
+		return lhsTrimmed + rhsTrimmed
+	}
+
+	switch strings.Count(whitespace, "\n") {
+	case 0:
+		return lhsTrimmed + " " + rhsTrimmed
+	case 1:
+		return lhsTrimmed + "\n" + rhsTrimmed
+	}
+
+	return lhsTrimmed + "\n\n" + rhsTrimmed
+}
+
+func renderNode(node *html.Node, width int, preserveWhitespace bool) (string, error) {
+	if node.Type == html.TextNode {
+		if !preserveWhitespace {
+			whitespace := regexp.MustCompile(`[ \t\n\r]+`)
+			return whitespace.ReplaceAllString(node.Data, " "), nil
+		}
+		return node.Data, nil
+	}
+
+	if node.Type != html.ElementNode {
+		return "", nil
+	}
+
+	content, err := renderChildren(node, width, preserveWhitespace)
+	if err != nil {
+		return "", err
+	}
+
+	switch node.Data {
+	case "a":
+		return style.Link(content), nil
+	case "s", "del":
+		return style.Strikethrough(content), nil
+	case "code":
+		return style.Code(content), nil
+	case "i", "em":
+		return style.Italic(content), nil
+	case "b", "strong":
+		return style.Bold(content), nil
+	case "u", "ins":
+		return style.Underline(content), nil
+	case "mark":
+		return style.Highlight(content), nil
+	case "span", "li", "small":
+		return content, nil
+	case "br":
+		return "\n", nil
+
+	case "p", "div":
+		return block(content), nil
+	case "pre":
+		content, err := renderChildren(node, width-2, true)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width, true)
+		return block(style.CodeBlock(wrapped)), err
+	case "blockquote":
+		content, err := renderChildren(node, width-1, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width-1, preserveWhitespace)
+		// TODO: this text wrap is ugly
+		return block(style.QuoteBlock(strings.Trim(wrapped, " \n"))), nil
+	case "ul":
+		list, err := bulletedList(node, width, preserveWhitespace)
+		return list, err
+	// case "ul":
+	// 	return numberedList(node), nil
+
+	case "h1":
+		content, err := renderChildren(node, width-2, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width-2, preserveWhitespace)
+		return block(style.Header(wrapped, 1)), nil
+	case "h2":
+		content, err := renderChildren(node, width-3, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width-3, preserveWhitespace)
+		return block(style.Header(wrapped, 2)), nil
+	case "h3":
+		content, err := renderChildren(node, width-4, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width-4, preserveWhitespace)
+		return block(style.Header(wrapped, 3)), nil
+	case "h4":
+		content, err := renderChildren(node, width-5, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width-5, preserveWhitespace)
+		return block(style.Header(wrapped, 4)), nil
+	case "h5":
+		content, err := renderChildren(node, width-6, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width-6, preserveWhitespace)
+		return block(style.Header(wrapped, 5)), nil
+	case "h6":
+		content, err := renderChildren(node, width-7, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(content, width-7, preserveWhitespace)
+		return block(style.Header(wrapped, 6)), nil
+
+	case "hr":
+		return block(strings.Repeat("―", width)), nil
+	case "img", "video", "audio", "iframe":
+		text := getAttribute("alt", node.Attr)
+		if text == "" {
+			text = getAttribute("title", node.Attr)
+		}
+		if text == "" {
+			text = getAttribute("src", node.Attr)
+		}
+		if text == "" {
+			return "", errors.New(node.Data + " tag is missing both `alt` and `src` attributes")
+		}
+		wrapped := situationalWrap(text, width-2, preserveWhitespace)
+		return block(style.LinkBlock(wrapped)), nil
+	}
+
+	return "", errors.New("Encountered unrecognized element " + node.Data)
+}
+
+func renderChildren(node *html.Node, width int, preserveWhitespace bool) (string, error) {
+	output := ""
+	for current := node.FirstChild; current != nil; current = current.NextSibling {
+		result, err := renderNode(current, width, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		output = mergeText(output, result)
+	}
+	return output, nil
+}
+
+func block(text string) string {
+	return "\n\n" + strings.Trim(text, " \n") + "\n\n"
+}
+
+func bulletedList(node *html.Node, width int, preserveWhitespace bool) (string, error) {
+	output := ""
+	for current := node.FirstChild; current != nil; current = current.NextSibling {
+		if current.Type != html.ElementNode {
+			continue
+		}
+
+		if current.Data != "li" {
+			continue
+		}
+
+		result, err := renderNode(current, width-2, preserveWhitespace)
+		if err != nil {
+			return "", err
+		}
+		wrapped := situationalWrap(result, width-2, preserveWhitespace)
+		output += "\n" + style.Bullet(wrapped)
+	}
+
+	if node.Parent == nil {
+		return block(output), nil
+	} else if node.Parent.Data == "li" {
+		return output, nil
+	} else {
+		return block(output), nil
+	}
+}
+
+func getAttribute(name string, attributes []html.Attribute) string {
+	for _, attribute := range attributes {
+		if attribute.Key == name {
+			return attribute.Val
+		}
+	}
+	return ""
+}
+
+func situationalWrap(text string, width int, preserveWhitespace bool) string {
+	if preserveWhitespace {
+		return ansi.DumbWrap(text, width)
+	}
+
+	return ansi.Wrap(text, width)
+}

+ 155 - 155
hypertext/hypertext_test.go

@@ -1,155 +1,155 @@
-package hypertext
-
-import (
-	"testing"
-	"mimicry/style"
-	"mimicry/util"
-)
-
-func TestMergeText(t *testing.T) {
-	lhs0 := "front"
-	rhs0 := "back"
-	output0 := mergeText(lhs0, rhs0)
-	expected0 := "frontback"
-	util.AssertEqual(expected0, output0, t)
-
-	lhs1 := "front     "
-	rhs1 := "   back"
-	output1 := mergeText(lhs1, rhs1)
-	expected1 := "front back"
-	util.AssertEqual(expected1, output1, t)
-
-	lhs2 := "front     "
-	rhs2 := " \n  back"
-	output2 := mergeText(lhs2, rhs2)
-	expected2 := "front\nback"
-	util.AssertEqual(expected2, output2, t)
-
-	lhs3 := "front    \n\n\n "
-	rhs3 := " \n  back"
-	output3 := mergeText(lhs3, rhs3)
-	expected3 := "front\n\nback"
-	util.AssertEqual(expected3, output3, t)
-}
-
-func TestStyles(t *testing.T) {
-	input := "<s>s</s><code>code</code><i>i</i><u>u</u><mark>mark</mark>"
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-	expected := style.Strikethrough("s") +
-		style.Code("code") +
-		style.Italic("i") +
-		style.Underline("u") +
-		style.Highlight("mark")
-
-	util.AssertEqual(expected, output, t)
-}
-
-func TestSurroundingBlocks(t *testing.T) {
-	input := "<p>first</p>in \t<mark>the</mark> \rmiddle<p>last</p>"
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-	expected := `first
-
-in ` + style.Highlight("the") + ` middle
-
-last`
-	util.AssertEqual(expected, output, t)
-}
-
-func TestAdjacentBlocks(t *testing.T) {
-	input := "\t<p>first</p>\n\t<p>second</p>"
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-	expected := `first
-
-second`
-	util.AssertEqual(expected, output, t)
-}
-
-func TestPoetry(t *testing.T) {
-	input := "he shouted\t\ta few words<br>at those annoying birds<br><br>and that they heard"
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-	expected := `he shouted a few words
-at those annoying birds
-
-and that they heard`
-
-	util.AssertEqual(expected, output, t)
-}
-
-// TODO: this is broken for now because my wrap algorithm removes
-// trailing spaces under certain conditions. I need to modify it such that it
-// leaves trailing spaces if possible
-func TestPreservation(t *testing.T) {
-	input := "<pre>multi-space   \n\n\n\n\n far down</pre>"
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-	expected := style.CodeBlock(`multi-space   
-
-
-
-
- far down`)
-	util.AssertEqual(expected, output, t)
-}
-
-func TestNestedBlocks(t *testing.T) {
-	input := `<p>Once a timid child</p>
-
-<p> </p>
-
-<p><img src="https://i.snap.as/P8qpdMbM.jpg" alt=""/></p>`
-	output, err := Render(input, 50)	
-	if err != nil {
-		panic(err)
-	}
-	expected := `Once a timid child
-
-` + style.LinkBlock("https://i.snap.as/P8qpdMbM.jpg")
-	util.AssertEqual(expected, output, t)
-}
-
-func TestAdjacentLists(t *testing.T) {
-	input := `<ul><li>top list</li></ul><ul><li>bottom list</li></ul>`
-	output, err := Render(input, 50)	
-	if err != nil {
-		panic(err)
-	}
-	expected := style.Bullet("top list") + "\n\n" +
-		style.Bullet("bottom list")
-	util.AssertEqual(expected, output, t)
-}
-
-func TestNestedLists(t *testing.T) {
-	input := `<ul><li>top list<ul><li>nested</li></ul></li></ul>`
-	output, err := Render(input, 50)	
-	if err != nil {
-		panic(err)
-	}
-	expected := style.Bullet("top list\n" + style.Bullet("nested"))
-		
-	util.AssertEqual(expected, output, t)
-}
-
-func TestBlockInList(t *testing.T) {
-	input := `<ul><li>top list<p><ul><li>paragraph</li></ul></p></li></ul>`
-	output, err := Render(input, 50)	
-	if err != nil {
-		panic(err)
-	}
-	expected := style.Bullet("top list\n\n" + style.Bullet("paragraph"))
-		
-	util.AssertEqual(expected, output, t)
-}
+package hypertext
+
+import (
+	"mimicry/style"
+	"mimicry/util"
+	"testing"
+)
+
+func TestMergeText(t *testing.T) {
+	lhs0 := "front"
+	rhs0 := "back"
+	output0 := mergeText(lhs0, rhs0)
+	expected0 := "frontback"
+	util.AssertEqual(expected0, output0, t)
+
+	lhs1 := "front     "
+	rhs1 := "   back"
+	output1 := mergeText(lhs1, rhs1)
+	expected1 := "front back"
+	util.AssertEqual(expected1, output1, t)
+
+	lhs2 := "front     "
+	rhs2 := " \n  back"
+	output2 := mergeText(lhs2, rhs2)
+	expected2 := "front\nback"
+	util.AssertEqual(expected2, output2, t)
+
+	lhs3 := "front    \n\n\n "
+	rhs3 := " \n  back"
+	output3 := mergeText(lhs3, rhs3)
+	expected3 := "front\n\nback"
+	util.AssertEqual(expected3, output3, t)
+}
+
+func TestStyles(t *testing.T) {
+	input := "<s>s</s><code>code</code><i>i</i><u>u</u><mark>mark</mark>"
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := style.Strikethrough("s") +
+		style.Code("code") +
+		style.Italic("i") +
+		style.Underline("u") +
+		style.Highlight("mark")
+
+	util.AssertEqual(expected, output, t)
+}
+
+func TestSurroundingBlocks(t *testing.T) {
+	input := "<p>first</p>in \t<mark>the</mark> \rmiddle<p>last</p>"
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := `first
+
+in ` + style.Highlight("the") + ` middle
+
+last`
+	util.AssertEqual(expected, output, t)
+}
+
+func TestAdjacentBlocks(t *testing.T) {
+	input := "\t<p>first</p>\n\t<p>second</p>"
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := `first
+
+second`
+	util.AssertEqual(expected, output, t)
+}
+
+func TestPoetry(t *testing.T) {
+	input := "he shouted\t\ta few words<br>at those annoying birds<br><br>and that they heard"
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := `he shouted a few words
+at those annoying birds
+
+and that they heard`
+
+	util.AssertEqual(expected, output, t)
+}
+
+// TODO: this is broken for now because my wrap algorithm removes
+// trailing spaces under certain conditions. I need to modify it such that it
+// leaves trailing spaces if possible
+func TestPreservation(t *testing.T) {
+	input := "<pre>multi-space   \n\n\n\n\n far down</pre>"
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := style.CodeBlock(`multi-space   
+
+
+
+
+ far down`)
+	util.AssertEqual(expected, output, t)
+}
+
+func TestNestedBlocks(t *testing.T) {
+	input := `<p>Once a timid child</p>
+
+<p> </p>
+
+<p><img src="https://i.snap.as/P8qpdMbM.jpg" alt=""/></p>`
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := `Once a timid child
+
+` + style.LinkBlock("https://i.snap.as/P8qpdMbM.jpg")
+	util.AssertEqual(expected, output, t)
+}
+
+func TestAdjacentLists(t *testing.T) {
+	input := `<ul><li>top list</li></ul><ul><li>bottom list</li></ul>`
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := style.Bullet("top list") + "\n\n" +
+		style.Bullet("bottom list")
+	util.AssertEqual(expected, output, t)
+}
+
+func TestNestedLists(t *testing.T) {
+	input := `<ul><li>top list<ul><li>nested</li></ul></li></ul>`
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := style.Bullet("top list\n" + style.Bullet("nested"))
+
+	util.AssertEqual(expected, output, t)
+}
+
+func TestBlockInList(t *testing.T) {
+	input := `<ul><li>top list<p><ul><li>paragraph</li></ul></p></li></ul>`
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+	expected := style.Bullet("top list\n\n" + style.Bullet("paragraph"))
+
+	util.AssertEqual(expected, output, t)
+}

+ 276 - 272
jtp/jtp.go

@@ -1,272 +1,276 @@
-package jtp
-
-import (
-	"regexp"
-	"errors"
-	"crypto/tls"
-	"net"
-	"net/url"
-	"bufio"
-	"fmt"
-	"strings"
-	"encoding/json"
-	"time"
-	lru "github.com/hashicorp/golang-lru/v2"
-)
-
-var dialer = &net.Dialer{
-	Timeout: 5 * time.Second,
-}
-
-type bundle struct {
-	item map[string]any
-	source *url.URL
-	err error
-}
-var cache, _ = lru.New[string, bundle](128)
-
-var mediaTypeRegexp = regexp.MustCompile(`(?s)^(([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)/([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)).*$`)
-var statusLineRegexp = regexp.MustCompile(`^HTTP/1\.[0-9] ([0-9]{3}).*\n$`)
-var contentTypeRegexp = regexp.MustCompile(`^(?i:content-type):[ \t\r]*(.*?)[ \t\r]*\n$`)
-var locationRegexp = regexp.MustCompile(`^(?i:location):[ \t\r]*(.*?)[ \t\r]*\n$`)
-
-/*
-	I send an HTTP/1.0 request to ensure the server doesn't respond
-	with chunked transfer encoding.
-	See: https://httpwg.org/specs/rfc9110.html
-*/
-
-/*
-	link
-		the url being requested
-	maxRedirects
-		the maximum number of redirects to take
-*/
-func Get(link *url.URL, accept string, tolerated []string, maxRedirects uint) (map[string]any, *url.URL, error) {
-	if cached, ok := cache.Get(link.String()); ok {
-		return cached.item, cached.source, cached.err
-	}
-
-	if link.Scheme != "https" {
-		return nil, nil, errors.New(link.Scheme + " is not supported in requests, only https")
-	}
-
-	port := link.Port()
-	if port == "" {
-		port = "443"
-	}
-
-	// TODO: link.Host may work instead of needing net.JoinHostPort
-	hostport := net.JoinHostPort(link.Hostname(), port)
-
-	connection, err := tls.DialWithDialer(dialer, "tcp", hostport, /*config =*/nil)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	_, err = connection.Write([]byte(
-		"GET " + link.RequestURI() + " HTTP/1.0\r\n" +
-		"Host: " + link.Host + "\r\n" +
-		"Accept: " + accept + "\r\n" +
-		"\r\n",
-	))
-	if err != nil {
-		return nil, nil, errors.Join(err, connection.Close())
-	}
-
-	buf := bufio.NewReader(connection)
-	statusLine, err := buf.ReadString('\n')
-	if err != nil {
-		return nil, nil, errors.Join(
-			fmt.Errorf("failed to parse HTTP status line: %w", err),
-			connection.Close(),
-		)
-	}
-
-	status, err := parseStatusLine(statusLine)
-	if err != nil {
-		return nil, nil, errors.Join(err, connection.Close())
-	}
-
-	if strings.HasPrefix(status, "3") {
-		location, err := findLocation(buf, link)
-		if err != nil {
-			return nil, nil, errors.Join(err, connection.Close())
-		}
-
-		if maxRedirects == 0 {
-			return nil, nil, errors.Join(
-				errors.New("Received " + status + " but max redirects has already been reached"),
-				connection.Close(),
-			)
-		}
-
-		if err := connection.Close(); err != nil {
-			return nil, nil, err
-		}
-		var b bundle
-		b.item, b.source, b.err = Get(location, accept, tolerated, maxRedirects - 1)
-		cache.Add(link.String(), b)
-		return b.item, b.source, b.err
-	}
-
-	if status != "200" && status != "201" && status != "202" && status != "203" {
-		return nil, nil, errors.Join(
-			errors.New("received invalid status " + status),
-			connection.Close(),
-		)
-	}
-
-	err = validateHeaders(buf, tolerated)
-	if err != nil {
-		return nil, nil, errors.Join(err, connection.Close())
-	}
-
-	var dictionary map[string]any
-	err = json.NewDecoder(buf).Decode(&dictionary)
-	if err != nil {
-		return nil, nil, errors.Join(
-			fmt.Errorf("failed to parse JSON: %w", err),
-			connection.Close(),
-		)
-	}
-
-	if err := connection.Close(); err != nil {
-		return nil, nil, err
-	}
-
-	cache.Add(link.String(), bundle {
-		item: dictionary,
-		source: link,
-		err: nil,
-	})
-	return dictionary, link, nil
-}
-
-func parseStatusLine(text string) (string, error) {
-	matches := statusLineRegexp.FindStringSubmatch(text)
-
-	if len(matches) != 2 {
-		return "", errors.New("Received invalid status line: " + text)
-	}
-
-	return matches[1], nil
-}
-
-func parseContentType(text string) (*MediaType, bool, error) {
-	matches := contentTypeRegexp.FindStringSubmatch(text)
-
-	if len(matches) != 2 {
-		return nil, false, nil
-	}
-
-	mediaType, err := ParseMediaType(matches[1])
-	if err != nil {
-		return nil, true, err
-	}
-
-	return mediaType, true, nil
-}
-
-func parseLocation(text string, baseLink *url.URL) (link *url.URL, isLocationLine bool, err error) {
-	matches := locationRegexp.FindStringSubmatch(text)
-
-	if len(matches) != 2 {
-		return nil, false, nil
-	}
-
-	reference, err := url.Parse(matches[1])
-	if err != nil {
-		return nil, true, err
-	}
-
-	return baseLink.ResolveReference(reference), true, nil
-}
-
-func validateHeaders(buf *bufio.Reader, tolerated []string) error {
-	contentTypeValidated := false
-	for {
-		line, err := buf.ReadString('\n')
-		if err != nil {
-			return err
-		}
-
-		if line == "\r\n" || line == "\n" {
-			break
-		}
-
-		mediaType, isContentTypeLine, err := parseContentType(line)
-		if err != nil {
-			return err
-		}
-		if !isContentTypeLine {
-			continue
-		}
-
-		if mediaType.Matches(tolerated) {
-			contentTypeValidated = true
-		} else {
-			return errors.New("Response contains invalid content type " + mediaType.Full)
-		}
-	}
-
-	if !contentTypeValidated {
-		return errors.New("Response did not contain a content type")
-	}
-
-	return nil
-}
-
-func findLocation(buf *bufio.Reader, baseLink *url.URL) (*url.URL, error) {
-	for {
-		line, err := buf.ReadString('\n')
-		if err != nil {
-			return nil, err
-		}
-
-		if line == "\r\n" || line == "\n" {
-			break
-		}
-
-		location, isLocationLine, err := parseLocation(line, baseLink)
-		if err != nil {
-			return nil, err
-		}
-		if !isLocationLine {
-			continue
-		}
-
-		return location, nil
-	}
-	return nil, errors.New("Location is not present in headers")
-}
-
-type MediaType struct {
-	Supertype string
-	Subtype string
-	/* Full omits the parameters */
-	Full string
-}
-
-func ParseMediaType(text string) (*MediaType, error) {
-	matches := mediaTypeRegexp.FindStringSubmatch(text)
-
-	if len(matches) != 4 {
-		return nil, errors.New(text + " is not a valid media type")
-	}
-
-	return &MediaType{
-		Supertype: matches[2],
-		Subtype: matches[3],
-		Full: matches[1],
-	}, nil
-}
-
-func (m *MediaType) Matches(mediaTypes []string) bool {
-	for _, mediaType := range mediaTypes {
-		if m.Full == mediaType {
-			return true
-		}
-	}
-	return false
-}
+package jtp
+
+import (
+	"bufio"
+	"crypto/tls"
+	"encoding/json"
+	"errors"
+	"fmt"
+	lru "github.com/hashicorp/golang-lru/v2"
+	"net"
+	"net/url"
+	"regexp"
+	"strings"
+	"time"
+)
+
+var dialer = &net.Dialer{
+	Timeout: 5 * time.Second,
+}
+
+type bundle struct {
+	item   map[string]any
+	source *url.URL
+	err    error
+}
+
+var cache, _ = lru.New[string, bundle](128)
+
+var mediaTypeRegexp = regexp.MustCompile(`(?s)^(([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)/([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)).*$`)
+var statusLineRegexp = regexp.MustCompile(`^HTTP/1\.[0-9] ([0-9]{3}).*\n$`)
+var contentTypeRegexp = regexp.MustCompile(`^(?i:content-type):[ \t\r]*(.*?)[ \t\r]*\n$`)
+var locationRegexp = regexp.MustCompile(`^(?i:location):[ \t\r]*(.*?)[ \t\r]*\n$`)
+
+/*
+	I send an HTTP/1.0 request to ensure the server doesn't respond
+	with chunked transfer encoding.
+	See: https://httpwg.org/specs/rfc9110.html
+*/
+
+/*
+link
+
+	the url being requested
+
+maxRedirects
+
+	the maximum number of redirects to take
+*/
+func Get(link *url.URL, accept string, tolerated []string, maxRedirects uint) (map[string]any, *url.URL, error) {
+	if cached, ok := cache.Get(link.String()); ok {
+		return cached.item, cached.source, cached.err
+	}
+
+	if link.Scheme != "https" {
+		return nil, nil, errors.New(link.Scheme + " is not supported in requests, only https")
+	}
+
+	port := link.Port()
+	if port == "" {
+		port = "443"
+	}
+
+	// TODO: link.Host may work instead of needing net.JoinHostPort
+	hostport := net.JoinHostPort(link.Hostname(), port)
+
+	connection, err := tls.DialWithDialer(dialer, "tcp", hostport /*config =*/, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	_, err = connection.Write([]byte(
+		"GET " + link.RequestURI() + " HTTP/1.0\r\n" +
+			"Host: " + link.Host + "\r\n" +
+			"Accept: " + accept + "\r\n" +
+			"\r\n",
+	))
+	if err != nil {
+		return nil, nil, errors.Join(err, connection.Close())
+	}
+
+	buf := bufio.NewReader(connection)
+	statusLine, err := buf.ReadString('\n')
+	if err != nil {
+		return nil, nil, errors.Join(
+			fmt.Errorf("failed to parse HTTP status line: %w", err),
+			connection.Close(),
+		)
+	}
+
+	status, err := parseStatusLine(statusLine)
+	if err != nil {
+		return nil, nil, errors.Join(err, connection.Close())
+	}
+
+	if strings.HasPrefix(status, "3") {
+		location, err := findLocation(buf, link)
+		if err != nil {
+			return nil, nil, errors.Join(err, connection.Close())
+		}
+
+		if maxRedirects == 0 {
+			return nil, nil, errors.Join(
+				errors.New("Received "+status+" but max redirects has already been reached"),
+				connection.Close(),
+			)
+		}
+
+		if err := connection.Close(); err != nil {
+			return nil, nil, err
+		}
+		var b bundle
+		b.item, b.source, b.err = Get(location, accept, tolerated, maxRedirects-1)
+		cache.Add(link.String(), b)
+		return b.item, b.source, b.err
+	}
+
+	if status != "200" && status != "201" && status != "202" && status != "203" {
+		return nil, nil, errors.Join(
+			errors.New("received invalid status "+status),
+			connection.Close(),
+		)
+	}
+
+	err = validateHeaders(buf, tolerated)
+	if err != nil {
+		return nil, nil, errors.Join(err, connection.Close())
+	}
+
+	var dictionary map[string]any
+	err = json.NewDecoder(buf).Decode(&dictionary)
+	if err != nil {
+		return nil, nil, errors.Join(
+			fmt.Errorf("failed to parse JSON: %w", err),
+			connection.Close(),
+		)
+	}
+
+	if err := connection.Close(); err != nil {
+		return nil, nil, err
+	}
+
+	cache.Add(link.String(), bundle{
+		item:   dictionary,
+		source: link,
+		err:    nil,
+	})
+	return dictionary, link, nil
+}
+
+func parseStatusLine(text string) (string, error) {
+	matches := statusLineRegexp.FindStringSubmatch(text)
+
+	if len(matches) != 2 {
+		return "", errors.New("Received invalid status line: " + text)
+	}
+
+	return matches[1], nil
+}
+
+func parseContentType(text string) (*MediaType, bool, error) {
+	matches := contentTypeRegexp.FindStringSubmatch(text)
+
+	if len(matches) != 2 {
+		return nil, false, nil
+	}
+
+	mediaType, err := ParseMediaType(matches[1])
+	if err != nil {
+		return nil, true, err
+	}
+
+	return mediaType, true, nil
+}
+
+func parseLocation(text string, baseLink *url.URL) (link *url.URL, isLocationLine bool, err error) {
+	matches := locationRegexp.FindStringSubmatch(text)
+
+	if len(matches) != 2 {
+		return nil, false, nil
+	}
+
+	reference, err := url.Parse(matches[1])
+	if err != nil {
+		return nil, true, err
+	}
+
+	return baseLink.ResolveReference(reference), true, nil
+}
+
+func validateHeaders(buf *bufio.Reader, tolerated []string) error {
+	contentTypeValidated := false
+	for {
+		line, err := buf.ReadString('\n')
+		if err != nil {
+			return err
+		}
+
+		if line == "\r\n" || line == "\n" {
+			break
+		}
+
+		mediaType, isContentTypeLine, err := parseContentType(line)
+		if err != nil {
+			return err
+		}
+		if !isContentTypeLine {
+			continue
+		}
+
+		if mediaType.Matches(tolerated) {
+			contentTypeValidated = true
+		} else {
+			return errors.New("Response contains invalid content type " + mediaType.Full)
+		}
+	}
+
+	if !contentTypeValidated {
+		return errors.New("Response did not contain a content type")
+	}
+
+	return nil
+}
+
+func findLocation(buf *bufio.Reader, baseLink *url.URL) (*url.URL, error) {
+	for {
+		line, err := buf.ReadString('\n')
+		if err != nil {
+			return nil, err
+		}
+
+		if line == "\r\n" || line == "\n" {
+			break
+		}
+
+		location, isLocationLine, err := parseLocation(line, baseLink)
+		if err != nil {
+			return nil, err
+		}
+		if !isLocationLine {
+			continue
+		}
+
+		return location, nil
+	}
+	return nil, errors.New("Location is not present in headers")
+}
+
+type MediaType struct {
+	Supertype string
+	Subtype   string
+	/* Full omits the parameters */
+	Full string
+}
+
+func ParseMediaType(text string) (*MediaType, error) {
+	matches := mediaTypeRegexp.FindStringSubmatch(text)
+
+	if len(matches) != 4 {
+		return nil, errors.New(text + " is not a valid media type")
+	}
+
+	return &MediaType{
+		Supertype: matches[2],
+		Subtype:   matches[3],
+		Full:      matches[1],
+	}, nil
+}
+
+func (m *MediaType) Matches(mediaTypes []string) bool {
+	for _, mediaType := range mediaTypes {
+		if m.Full == mediaType {
+			return true
+		}
+	}
+	return false
+}

+ 58 - 58
jtp/jtp_test.go

@@ -1,58 +1,58 @@
-package jtp
-
-import (
-	"testing"
-	"mimicry/util"
-	"net/url"
-)
-
-func TestStatusLineNoInfo(t *testing.T) {
-	test := "HTTP/1.1 200\r\n"
-	status, err := parseStatusLine(test)
-	if err != nil {
-		panic(err)
-	}
-	util.AssertEqual("200", status, t)
-}
-
-// TODO: put this behind an --online flag or figure out
-// how to nicely do offline tests
-func TestRedirect(t *testing.T) {
-	accept := "application/activity+json"
-	tolerated := []string{"application/json"}
-
-	link, err := url.Parse("https://httpbin.org/redirect/5")
-	if err != nil {
-		t.Fatalf("invalid url literal: %s", err)
-	}
-
-	_, actualLink, err := Get(link, accept, tolerated, 5)
-
-	if err != nil {
-		t.Fatalf("failed to preform request: %s", err)
-	}
-
-	if link.String() == actualLink.String() {
-		t.Fatalf("failed to return the underlying url redirected to by %s", link.String())
-	}
-}
-
-func TestBasic(t *testing.T) {
-	accept := "application/activity+json"
-	tolerated := []string{"application/json"}
-
-	link, err := url.Parse("https://httpbin.org/get")
-	if err != nil {
-		t.Fatalf("invalid url literal: %s", err)
-	}
-
-	_, actualLink, err := Get(link, accept, tolerated, 20)
-
-	if err != nil {
-		t.Fatalf("failed to preform request: %s", err)
-	}
-
-	if link.String() != actualLink.String() {
-		t.Fatalf("underlying url %s should match request url %s", actualLink.String(), link.String())
-	}
-}
+package jtp
+
+import (
+	"mimicry/util"
+	"net/url"
+	"testing"
+)
+
+func TestStatusLineNoInfo(t *testing.T) {
+	test := "HTTP/1.1 200\r\n"
+	status, err := parseStatusLine(test)
+	if err != nil {
+		panic(err)
+	}
+	util.AssertEqual("200", status, t)
+}
+
+// TODO: put this behind an --online flag or figure out
+// how to nicely do offline tests
+func TestRedirect(t *testing.T) {
+	accept := "application/activity+json"
+	tolerated := []string{"application/json"}
+
+	link, err := url.Parse("https://httpbin.org/redirect/5")
+	if err != nil {
+		t.Fatalf("invalid url literal: %s", err)
+	}
+
+	_, actualLink, err := Get(link, accept, tolerated, 5)
+
+	if err != nil {
+		t.Fatalf("failed to preform request: %s", err)
+	}
+
+	if link.String() == actualLink.String() {
+		t.Fatalf("failed to return the underlying url redirected to by %s", link.String())
+	}
+}
+
+func TestBasic(t *testing.T) {
+	accept := "application/activity+json"
+	tolerated := []string{"application/json"}
+
+	link, err := url.Parse("https://httpbin.org/get")
+	if err != nil {
+		t.Fatalf("invalid url literal: %s", err)
+	}
+
+	_, actualLink, err := Get(link, accept, tolerated, 20)
+
+	if err != nil {
+		t.Fatalf("failed to preform request: %s", err)
+	}
+
+	if link.String() != actualLink.String() {
+		t.Fatalf("underlying url %s should match request url %s", actualLink.String(), link.String())
+	}
+}

+ 24 - 24
markdown/markdown.go

@@ -1,24 +1,24 @@
-package markdown
-
-import (
-    "bytes"
-    "github.com/yuin/goldmark"
-	"github.com/yuin/goldmark/extension"
-	"mimicry/hypertext"
-	"strings"
-)
-
-var renderer = goldmark.New(goldmark.WithExtensions(extension.GFM))
-
-func Render(text string, width int) (string, error) {
-	var buf bytes.Buffer
-	if err := renderer.Convert([]byte(text), &buf); err != nil {
-		return "", nil
-	}
-	output := buf.String()
-	rendered, err := hypertext.Render(output, width)
-	if err != nil {
-		return "", err
-	}
-	return strings.TrimSpace(rendered), nil
-}
+package markdown
+
+import (
+	"bytes"
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/extension"
+	"mimicry/hypertext"
+	"strings"
+)
+
+var renderer = goldmark.New(goldmark.WithExtensions(extension.GFM))
+
+func Render(text string, width int) (string, error) {
+	var buf bytes.Buffer
+	if err := renderer.Convert([]byte(text), &buf); err != nil {
+		return "", nil
+	}
+	output := buf.String()
+	rendered, err := hypertext.Render(output, width)
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(rendered), nil
+}

+ 26 - 26
markdown/markdown_test.go

@@ -1,26 +1,26 @@
-package markdown
-
-import (
-	"mimicry/util"
-	"testing"
-	"mimicry/style"
-)
-
-func TestBasic(t *testing.T) {
-	input := `[Here's a link!](https://wikipedia.org)
-
-![This is a beautiful image!](https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Francesco_Melzi_-_Portrait_of_Leonardo.png/800px-Francesco_Melzi_-_Portrait_of_Leonardo.png)
-
-* Nested list
-  * Nesting`
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-
-	expected := style.Link("Here's a link!") + "\n\n" +
-		style.LinkBlock("This is a beautiful image!") + "\n\n" +
-		style.Bullet("Nested list\n" + style.Bullet("Nesting"))
-
-	util.AssertEqual(expected, output, t)
-}
+package markdown
+
+import (
+	"mimicry/style"
+	"mimicry/util"
+	"testing"
+)
+
+func TestBasic(t *testing.T) {
+	input := `[Here's a link!](https://wikipedia.org)
+
+![This is a beautiful image!](https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Francesco_Melzi_-_Portrait_of_Leonardo.png/800px-Francesco_Melzi_-_Portrait_of_Leonardo.png)
+
+* Nested list
+  * Nesting`
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+
+	expected := style.Link("Here's a link!") + "\n\n" +
+		style.LinkBlock("This is a beautiful image!") + "\n\n" +
+		style.Bullet("Nested list\n"+style.Bullet("Nesting"))
+
+	util.AssertEqual(expected, output, t)
+}

+ 57 - 55
mime/mime.go

@@ -1,55 +1,57 @@
-package mime
-
-import (
-	"regexp"
-	"errors"
-)
-
-type MediaType struct {
-	Essence string
-	Supertype string
-	Subtype string
-}
-
-/*
-	See: https://httpwg.org/specs/rfc9110.html#media.type
-*/
-var re = regexp.MustCompile(`(?s)^(([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)/([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)).*$`)
-
-func Default() *MediaType {
-	return &MediaType {
-		Essence: "text/html",
-		Supertype: "text",
-		Subtype: "html",
-	}
-}
-
-func Parse(input string) (*MediaType, error) {
-	matches := re.FindStringSubmatch(input)
-
-	if len(matches) != 4 {
-		return nil, errors.New(`"` + input + `" is not a valid media type`)
-	}
-
-	return &MediaType{
-		Essence: matches[1],
-		Supertype: matches[2],
-		Subtype: matches[3],
-	}, nil
-}
-
-func (m *MediaType) Update(input string) error {
-	parsed, err := Parse(input)
-	if err != nil { return err }
-	*m = *parsed
-	return nil
-}
-
-func (m *MediaType) Matches(mediaTypes []string) bool {
-	for _, mediaType := range mediaTypes {
-		if m.Essence == mediaType {
-			return true
-		}
-	}
-	return false
-}
+package mime
+
+import (
+	"errors"
+	"regexp"
+)
+
+type MediaType struct {
+	Essence   string
+	Supertype string
+	Subtype   string
+}
+
+/*
+See: https://httpwg.org/specs/rfc9110.html#media.type
+*/
+var re = regexp.MustCompile(`(?s)^(([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)/([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)).*$`)
+
+func Default() *MediaType {
+	return &MediaType{
+		Essence:   "text/html",
+		Supertype: "text",
+		Subtype:   "html",
+	}
+}
+
+func Parse(input string) (*MediaType, error) {
+	matches := re.FindStringSubmatch(input)
+
+	if len(matches) != 4 {
+		return nil, errors.New(`"` + input + `" is not a valid media type`)
+	}
+
+	return &MediaType{
+		Essence:   matches[1],
+		Supertype: matches[2],
+		Subtype:   matches[3],
+	}, nil
+}
+
+func (m *MediaType) Update(input string) error {
+	parsed, err := Parse(input)
+	if err != nil {
+		return err
+	}
+	*m = *parsed
+	return nil
+}
+
+func (m *MediaType) Matches(mediaTypes []string) bool {
+	for _, mediaType := range mediaTypes {
+		if m.Essence == mediaType {
+			return true
+		}
+	}
+	return false
+}

+ 71 - 71
mime/mime_test.go

@@ -1,71 +1,71 @@
-package mime
-
-import (
-	"testing"
-)
-
-func TestDefault(t *testing.T) {
-	m := Default()
-	if m.Essence != "text/html" {
-		t.Fatalf(`Default media type should be "text/html", not %#v`, m.Essence)
-	}
-	if m.Supertype != "text" {
-		t.Fatalf(`Default media type supertype should be "text", not %#v`, m.Supertype)
-	}
-	if m.Subtype != "html" {
-		t.Fatalf(`Default media type subtype should be "html", not %#v`, m.Subtype)
-	}
-}
-
-func TestFailedParse(t *testing.T) {
-	m, err := Parse("")
-	if err == nil {
-		t.Fatalf("Should fail to parse an empty string, but instead returns: %#v", m)
-	}
-
-	m, err = Parse("application")
-	if err == nil {
-		t.Fatalf("Should fail to parse invalid media type, but instead returns: %#v", m)
-	}
-}
-
-func TestSuccessfulUpdate(t *testing.T) {
-	m := Default()
-	err := m.Update("application/json ; charset=utf-8")
-	if err != nil {
-		t.Fatalf("Update should have succeeded but returned error: %v", err)
-	}
-	if m.Essence != "application/json" {
-		t.Fatalf(`New media type should be "application/json", not %#v`, m.Essence)
-	}
-	if m.Supertype != "application" {
-		t.Fatalf(`New media type supertype should be "application", not %#v`, m.Supertype)
-	}
-	if m.Subtype != "json" {
-		t.Fatalf(`New media type subtype should be "json", not %#v`, m.Subtype)
-	}
-}
-
-func TestFailedUpdate(t *testing.T) {
-	m := Default()
-	err := m.Update("no slash")
-	if err == nil {
-		t.Fatalf(`Expected "no slash" to result in an Update error, but it resulted in: %#v`, m)
-	}
-}
-
-func TestMatchesSuccess(t *testing.T) {
-	m := Default()
-	matches := m.Matches([]string{"application/json", "text/html"})
-	if !matches {
-		t.Fatalf(`Expected media type to match text/html but it did not: %#v`, m)
-	}
-}
-
-func TestMatchesFailure(t *testing.T) {
-	m := Default()
-	matches := m.Matches([]string{"application/json"})
-	if matches {
-		t.Fatalf(`Expected media type to not match application/json: %#v`, m)
-	}
-}
+package mime
+
+import (
+	"testing"
+)
+
+func TestDefault(t *testing.T) {
+	m := Default()
+	if m.Essence != "text/html" {
+		t.Fatalf(`Default media type should be "text/html", not %#v`, m.Essence)
+	}
+	if m.Supertype != "text" {
+		t.Fatalf(`Default media type supertype should be "text", not %#v`, m.Supertype)
+	}
+	if m.Subtype != "html" {
+		t.Fatalf(`Default media type subtype should be "html", not %#v`, m.Subtype)
+	}
+}
+
+func TestFailedParse(t *testing.T) {
+	m, err := Parse("")
+	if err == nil {
+		t.Fatalf("Should fail to parse an empty string, but instead returns: %#v", m)
+	}
+
+	m, err = Parse("application")
+	if err == nil {
+		t.Fatalf("Should fail to parse invalid media type, but instead returns: %#v", m)
+	}
+}
+
+func TestSuccessfulUpdate(t *testing.T) {
+	m := Default()
+	err := m.Update("application/json ; charset=utf-8")
+	if err != nil {
+		t.Fatalf("Update should have succeeded but returned error: %v", err)
+	}
+	if m.Essence != "application/json" {
+		t.Fatalf(`New media type should be "application/json", not %#v`, m.Essence)
+	}
+	if m.Supertype != "application" {
+		t.Fatalf(`New media type supertype should be "application", not %#v`, m.Supertype)
+	}
+	if m.Subtype != "json" {
+		t.Fatalf(`New media type subtype should be "json", not %#v`, m.Subtype)
+	}
+}
+
+func TestFailedUpdate(t *testing.T) {
+	m := Default()
+	err := m.Update("no slash")
+	if err == nil {
+		t.Fatalf(`Expected "no slash" to result in an Update error, but it resulted in: %#v`, m)
+	}
+}
+
+func TestMatchesSuccess(t *testing.T) {
+	m := Default()
+	matches := m.Matches([]string{"application/json", "text/html"})
+	if !matches {
+		t.Fatalf(`Expected media type to match text/html but it did not: %#v`, m)
+	}
+}
+
+func TestMatchesFailure(t *testing.T) {
+	m := Default()
+	matches := m.Matches([]string{"application/json"})
+	if matches {
+		t.Fatalf(`Expected media type to not match application/json: %#v`, m)
+	}
+}

+ 130 - 130
object/object.go

@@ -1,130 +1,130 @@
-package object
-
-import (
-	"errors"
-	"net/url"
-	"time"
-	"mimicry/mime"
-	"fmt"
-)
-
-type Object map[string]any
-
-var (
-	ErrKeyNotPresent = errors.New("key is not present")
-	ErrKeyWrongType = errors.New("key is incorrect type")
-)
-
-/* Go doesn't allow generic methods */
-func getPrimitive[T any](o Object, key string) (T, error) {
-	var zero T
-	if value, ok := o[key]; !ok || value == nil {
-		return zero, fmt.Errorf("failed to extract \"%s\": %w", key, ErrKeyNotPresent)
-	} else if narrowed, ok := value.(T); !ok {
-		return zero, fmt.Errorf("failed to extract \"%s\": %w: is %T", key, ErrKeyWrongType, value)
-	} else {
-		return narrowed, nil
-	}
-}
-
-func (o Object) GetAny(key string) (any, error) {
-	return getPrimitive[any](o, key)
-}
-
-func (o Object) GetString(key string) (string, error) {
-	return getPrimitive[string](o, key)
-}
-
-// TODO: should probably error for non-uints
-func (o Object) GetNumber(key string) (uint64, error) {
-	if number, err := getPrimitive[float64](o, key); err != nil {
-		return 0, err
-	} else {
-		return uint64(number), nil
-	}
-}
-
-func (o Object) GetObject(key string) (Object, error) {
-	return getPrimitive[map[string]any](o, key)
-}
-
-func (o Object) GetList(key string) ([]any, error) {
-	if value, err := o.GetAny(key); err != nil {
-		return nil, err
-	} else if asList, isList := value.([]any); isList {
-		return asList, nil
-	} else {
-		return []any{value}, nil
-	}
-}
-
-func (o Object) GetTime(key string) (time.Time, error) {
-	if value, err := o.GetString(key); err != nil {
-		return time.Time{}, err
-	} else {
-		timestamp, err := time.Parse(time.RFC3339, value)
-		if err != nil {
-			return time.Time{}, fmt.Errorf("failed to parse time \"%s\": %w", key, err)
-		}
-		return timestamp, nil
-	}
-}
-
-func (o Object) GetURL(key string) (*url.URL, error) {
-	if value, err := o.GetString(key); err != nil {
-		return nil, err
-	} else {
-		address, err := url.Parse(value)
-		if err != nil {
-			return nil, fmt.Errorf("failed to parse URL \"%s\": %w", key, err)
-		}
-		return address, nil
-	}
-}
-
-func (o Object) GetMediaType(key string) (*mime.MediaType, error) {
-	if value, err := o.GetString(key); err != nil {
-		return nil, err
-	} else {
-		mediaType, err := mime.Parse(value)
-		if err != nil {
-			return nil, fmt.Errorf("failed to parse mime type \"%s\": %w", key, err)
-		}
-		return mediaType, nil
-	}
-}
-
-/* https://www.w3.org/TR/activitystreams-core/#naturalLanguageValues */
-func (o Object) GetNatural(key string, language string) (string, error) {
-	values, err := o.GetObject(key+"Map")
-	hasMap := true
-	if errors.Is(err, ErrKeyNotPresent) {
-		hasMap = false
-	} else if err != nil {
-		return "", err
-	}
-
-	if hasMap {
-		if value, err := values.GetString(language); err == nil {
-			return value, nil
-		} else if !errors.Is(err, ErrKeyNotPresent) {
-			return "", fmt.Errorf("failed to extract from \"%s\": %w", key+"Map", err)
-		}
-	}
-
-	if value, err := o.GetString(key); err == nil {
-		return value, nil
-	} else if !errors.Is(err, ErrKeyNotPresent) {
-		return "", err
-	}
-
-	if hasMap {
-		if value, err := values.GetString("und"); err == nil {
-			return value, nil
-		} else if !errors.Is(err, ErrKeyNotPresent) {
-			return "", fmt.Errorf("failed to extract from \"%s\": %w", key+"Map", err)
-		}
-	}
-
-	return "", fmt.Errorf("failed to extract natural \"%s\": %w", key, ErrKeyNotPresent)
-}
+package object
+
+import (
+	"errors"
+	"fmt"
+	"mimicry/mime"
+	"net/url"
+	"time"
+)
+
+type Object map[string]any
+
+var (
+	ErrKeyNotPresent = errors.New("key is not present")
+	ErrKeyWrongType  = errors.New("key is incorrect type")
+)
+
+/* Go doesn't allow generic methods */
+func getPrimitive[T any](o Object, key string) (T, error) {
+	var zero T
+	if value, ok := o[key]; !ok || value == nil {
+		return zero, fmt.Errorf("failed to extract \"%s\": %w", key, ErrKeyNotPresent)
+	} else if narrowed, ok := value.(T); !ok {
+		return zero, fmt.Errorf("failed to extract \"%s\": %w: is %T", key, ErrKeyWrongType, value)
+	} else {
+		return narrowed, nil
+	}
+}
+
+func (o Object) GetAny(key string) (any, error) {
+	return getPrimitive[any](o, key)
+}
+
+func (o Object) GetString(key string) (string, error) {
+	return getPrimitive[string](o, key)
+}
+
+// TODO: should probably error for non-uints
+func (o Object) GetNumber(key string) (uint64, error) {
+	if number, err := getPrimitive[float64](o, key); err != nil {
+		return 0, err
+	} else {
+		return uint64(number), nil
+	}
+}
+
+func (o Object) GetObject(key string) (Object, error) {
+	return getPrimitive[map[string]any](o, key)
+}
+
+func (o Object) GetList(key string) ([]any, error) {
+	if value, err := o.GetAny(key); err != nil {
+		return nil, err
+	} else if asList, isList := value.([]any); isList {
+		return asList, nil
+	} else {
+		return []any{value}, nil
+	}
+}
+
+func (o Object) GetTime(key string) (time.Time, error) {
+	if value, err := o.GetString(key); err != nil {
+		return time.Time{}, err
+	} else {
+		timestamp, err := time.Parse(time.RFC3339, value)
+		if err != nil {
+			return time.Time{}, fmt.Errorf("failed to parse time \"%s\": %w", key, err)
+		}
+		return timestamp, nil
+	}
+}
+
+func (o Object) GetURL(key string) (*url.URL, error) {
+	if value, err := o.GetString(key); err != nil {
+		return nil, err
+	} else {
+		address, err := url.Parse(value)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse URL \"%s\": %w", key, err)
+		}
+		return address, nil
+	}
+}
+
+func (o Object) GetMediaType(key string) (*mime.MediaType, error) {
+	if value, err := o.GetString(key); err != nil {
+		return nil, err
+	} else {
+		mediaType, err := mime.Parse(value)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse mime type \"%s\": %w", key, err)
+		}
+		return mediaType, nil
+	}
+}
+
+/* https://www.w3.org/TR/activitystreams-core/#naturalLanguageValues */
+func (o Object) GetNatural(key string, language string) (string, error) {
+	values, err := o.GetObject(key + "Map")
+	hasMap := true
+	if errors.Is(err, ErrKeyNotPresent) {
+		hasMap = false
+	} else if err != nil {
+		return "", err
+	}
+
+	if hasMap {
+		if value, err := values.GetString(language); err == nil {
+			return value, nil
+		} else if !errors.Is(err, ErrKeyNotPresent) {
+			return "", fmt.Errorf("failed to extract from \"%s\": %w", key+"Map", err)
+		}
+	}
+
+	if value, err := o.GetString(key); err == nil {
+		return value, nil
+	} else if !errors.Is(err, ErrKeyNotPresent) {
+		return "", err
+	}
+
+	if hasMap {
+		if value, err := values.GetString("und"); err == nil {
+			return value, nil
+		} else if !errors.Is(err, ErrKeyNotPresent) {
+			return "", fmt.Errorf("failed to extract from \"%s\": %w", key+"Map", err)
+		}
+	}
+
+	return "", fmt.Errorf("failed to extract natural \"%s\": %w", key, ErrKeyNotPresent)
+}

+ 166 - 128
object/object_test.go

@@ -1,128 +1,166 @@
-package object
-
-import (
-	"testing"
-	"errors"
-	// "encoding/json"
-)
-
-func TestString(t *testing.T) {
-	o := Object {
-		"good": "value",
-		"bad": float64(25),
-		// deliberately absent: "absent": "value",
-	}
-	str, err := o.GetString("good")
-	if err != nil { t.Fatalf("Problem extracting string: %v", err) }
-	if str != "value" { t.Fatalf(`Expected "value" not %v`, str) }
-
-	_, err = o.GetString("bad")
-	if !errors.Is(err, ErrKeyWrongType) { t.Fatalf(`Expected ErrKeyWrongType, not %v`, err) }
-
-	_, err = o.GetString("absent")
-	if !errors.Is(err, ErrKeyNotPresent) { t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err) }
-}
-
-func TestNumber(t *testing.T) {
-	o := Object {
-		"good": float64(25),
-		"bad": "value",
-		// deliberately absent: "absent": "value",
-	}
-	num, err := o.GetNumber("good")
-	if err != nil { t.Fatalf("Problem extracting number: %v", err) }
-	if num != 25 { t.Fatalf(`Expected 25 not %v`, num) }
-
-	_, err = o.GetNumber("bad")
-	if !errors.Is(err, ErrKeyWrongType) { t.Fatalf(`Expected ErrKeyWrongType, not %v`, err) }
-
-	_, err = o.GetNumber("absent")
-	if !errors.Is(err, ErrKeyNotPresent) { t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err) }
-}
-
-func TestObject(t *testing.T) {
-	o := Object {
-		"good": map[string]any{},
-		"bad": "value",
-		// deliberately absent: "absent": "value",
-	}
-	obj, err := o.GetObject("good")
-	if err != nil { t.Fatalf("Problem extracting Object: %v", err) }
-	if len(obj) != 0 { t.Fatalf(`Expected empty map, not %v`, obj) }
-
-	_, err = o.GetObject("bad")
-	if !errors.Is(err, ErrKeyWrongType) { t.Fatalf(`Expected ErrKeyWrongType, not %v`, err) }
-
-	_, err = o.GetObject("absent")
-	if !errors.Is(err, ErrKeyNotPresent) { t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err) }
-}
-
-func TestList(t *testing.T) {
-	o := Object {
-		"multiple": []any{"first", "second"},
-		"single": "one",
-		// deliberately absent: "absent": "value",
-	}
-	list, err := o.GetList("multiple")
-	if err != nil { t.Fatalf("Problem extracting list: %v", err) }
-	if len(list) != 2 { t.Fatalf(`Expected 2 elements, but didn't get them: %v`, list) }
-
-	list, err = o.GetList("single")
-	if err != nil { t.Fatalf("Problem extracting list: %v", err) }
-	if len(list) != 1 { t.Fatalf(`Expected 1 element to auto-convert to list, but didn't: %v`, list) }
-
-	_, err = o.GetList("absent")
-	if !errors.Is(err, ErrKeyNotPresent) { t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err) }
-}
-
-func TestNatural(t *testing.T) {
-	// desired key should have value "target"
-	// language that is targeted is "en"
-	tests := []Object {
-		// TODO: this hasn't been implemented
-		// I will want it to be deterministic, so will need to sort by key and then take the first one
-		// // fall back to first element of map if nothing better
-		// {
-		// 	"contentMap": map[string]any {
-		// 		"fr": "target",
-		// 	},
-		// },
-
-		// use "und" if nothing better
-		{
-			"contentMap": map[string]any {
-				"und": "target",
-				"fr": "ignored",
-			},
-		},
-
-		// use the key itself if nothing better
-		{
-			"content": "target",
-			"contentMap": map[string]any {
-				"und": "ignored",
-				"fr": "ignored",
-			},
-		},
-
-		// use the desired language if possible
-		{
-			"content": "ignored",
-			"contentMap": map[string]any {
-				"en": "target",
-				"und": "ignored",
-				"fr": "ignored",
-			},
-		},
-
-		// use key itself if map is absent
-		{
-			"content": "target",
-		},
-	}
-	for i, test := range tests {
-		response, err := test.GetNatural("content", "en")
-		if err != nil { t.Fatalf("Problem extracting natural in case %v: %v", i, err) }
-		if response != "target" { t.Fatalf(`Expected natural value in case %v to return "target", not %#v`, i, response) }
-	}
-}
+package object
+
+import (
+	"errors"
+	"testing"
+	// "encoding/json"
+)
+
+func TestString(t *testing.T) {
+	o := Object{
+		"good": "value",
+		"bad":  float64(25),
+		// deliberately absent: "absent": "value",
+	}
+	str, err := o.GetString("good")
+	if err != nil {
+		t.Fatalf("Problem extracting string: %v", err)
+	}
+	if str != "value" {
+		t.Fatalf(`Expected "value" not %v`, str)
+	}
+
+	_, err = o.GetString("bad")
+	if !errors.Is(err, ErrKeyWrongType) {
+		t.Fatalf(`Expected ErrKeyWrongType, not %v`, err)
+	}
+
+	_, err = o.GetString("absent")
+	if !errors.Is(err, ErrKeyNotPresent) {
+		t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err)
+	}
+}
+
+func TestNumber(t *testing.T) {
+	o := Object{
+		"good": float64(25),
+		"bad":  "value",
+		// deliberately absent: "absent": "value",
+	}
+	num, err := o.GetNumber("good")
+	if err != nil {
+		t.Fatalf("Problem extracting number: %v", err)
+	}
+	if num != 25 {
+		t.Fatalf(`Expected 25 not %v`, num)
+	}
+
+	_, err = o.GetNumber("bad")
+	if !errors.Is(err, ErrKeyWrongType) {
+		t.Fatalf(`Expected ErrKeyWrongType, not %v`, err)
+	}
+
+	_, err = o.GetNumber("absent")
+	if !errors.Is(err, ErrKeyNotPresent) {
+		t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err)
+	}
+}
+
+func TestObject(t *testing.T) {
+	o := Object{
+		"good": map[string]any{},
+		"bad":  "value",
+		// deliberately absent: "absent": "value",
+	}
+	obj, err := o.GetObject("good")
+	if err != nil {
+		t.Fatalf("Problem extracting Object: %v", err)
+	}
+	if len(obj) != 0 {
+		t.Fatalf(`Expected empty map, not %v`, obj)
+	}
+
+	_, err = o.GetObject("bad")
+	if !errors.Is(err, ErrKeyWrongType) {
+		t.Fatalf(`Expected ErrKeyWrongType, not %v`, err)
+	}
+
+	_, err = o.GetObject("absent")
+	if !errors.Is(err, ErrKeyNotPresent) {
+		t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err)
+	}
+}
+
+func TestList(t *testing.T) {
+	o := Object{
+		"multiple": []any{"first", "second"},
+		"single":   "one",
+		// deliberately absent: "absent": "value",
+	}
+	list, err := o.GetList("multiple")
+	if err != nil {
+		t.Fatalf("Problem extracting list: %v", err)
+	}
+	if len(list) != 2 {
+		t.Fatalf(`Expected 2 elements, but didn't get them: %v`, list)
+	}
+
+	list, err = o.GetList("single")
+	if err != nil {
+		t.Fatalf("Problem extracting list: %v", err)
+	}
+	if len(list) != 1 {
+		t.Fatalf(`Expected 1 element to auto-convert to list, but didn't: %v`, list)
+	}
+
+	_, err = o.GetList("absent")
+	if !errors.Is(err, ErrKeyNotPresent) {
+		t.Fatalf(`Expected ErrKeyNotPresent, not %v`, err)
+	}
+}
+
+func TestNatural(t *testing.T) {
+	// desired key should have value "target"
+	// language that is targeted is "en"
+	tests := []Object{
+		// TODO: this hasn't been implemented
+		// I will want it to be deterministic, so will need to sort by key and then take the first one
+		// // fall back to first element of map if nothing better
+		// {
+		// 	"contentMap": map[string]any {
+		// 		"fr": "target",
+		// 	},
+		// },
+
+		// use "und" if nothing better
+		{
+			"contentMap": map[string]any{
+				"und": "target",
+				"fr":  "ignored",
+			},
+		},
+
+		// use the key itself if nothing better
+		{
+			"content": "target",
+			"contentMap": map[string]any{
+				"und": "ignored",
+				"fr":  "ignored",
+			},
+		},
+
+		// use the desired language if possible
+		{
+			"content": "ignored",
+			"contentMap": map[string]any{
+				"en":  "target",
+				"und": "ignored",
+				"fr":  "ignored",
+			},
+		},
+
+		// use key itself if map is absent
+		{
+			"content": "target",
+		},
+	}
+	for i, test := range tests {
+		response, err := test.GetNatural("content", "en")
+		if err != nil {
+			t.Fatalf("Problem extracting natural in case %v: %v", i, err)
+		}
+		if response != "target" {
+			t.Fatalf(`Expected natural value in case %v to return "target", not %#v`, i, response)
+		}
+	}
+}

+ 25 - 25
plaintext/plaintext.go

@@ -1,25 +1,25 @@
-package plaintext
-
-import (
-	"regexp"
-	"mimicry/style"
-	"strings"
-	"mimicry/ansi"
-)
-
-func Render(text string, width int) (string, error) {
-	/*
-		Oversimplistic URL regexp based on RFC 3986, Appendix A
-		It matches:
-			<scheme>://<hierarchy>
-		Where
-			<scheme> is ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
-			<hierarchy> is any of the characters listed in Appendix A:
-				A-Z a-z 0-9 - . ? # / @ : [ ] % _ ~ ! $ & ' ( ) * + , ; =
-	*/
-
-	url := regexp.MustCompile(`[A-Za-z][A-Za-z0-9+\-.]*://[A-Za-z0-9.?#/@:%_~!$&'()*+,;=\[\]\-]+`)
-	rendered := url.ReplaceAllStringFunc(text, style.Link)
-	wrapped := ansi.Wrap(rendered, width)
-	return strings.TrimSpace(wrapped), nil
-}
+package plaintext
+
+import (
+	"mimicry/ansi"
+	"mimicry/style"
+	"regexp"
+	"strings"
+)
+
+func Render(text string, width int) (string, error) {
+	/*
+		Oversimplistic URL regexp based on RFC 3986, Appendix A
+		It matches:
+			<scheme>://<hierarchy>
+		Where
+			<scheme> is ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
+			<hierarchy> is any of the characters listed in Appendix A:
+				A-Z a-z 0-9 - . ? # / @ : [ ] % _ ~ ! $ & ' ( ) * + , ; =
+	*/
+
+	url := regexp.MustCompile(`[A-Za-z][A-Za-z0-9+\-.]*://[A-Za-z0-9.?#/@:%_~!$&'()*+,;=\[\]\-]+`)
+	rendered := url.ReplaceAllStringFunc(text, style.Link)
+	wrapped := ansi.Wrap(rendered, width)
+	return strings.TrimSpace(wrapped), nil
+}

+ 24 - 24
plaintext/plaintext_test.go

@@ -1,24 +1,24 @@
-package plaintext
-
-import (
-	"testing"
-	"mimicry/style"
-	"mimicry/util"
-	"mimicry/ansi"
-)
-
-func TestBasic(t *testing.T) {
-	input := `Yes, Jim, I found it under "http://www.w3.org/Addressing/",
-but you can probably pick it up from <ftp://foo.example.com/rfc/>.
-Note the warning in <http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING>.`
-	output, err := Render(input, 50)
-	if err != nil {
-		panic(err)
-	}
-
-	expected := ansi.Wrap("Yes, Jim, I found it under \"" + style.Link("http://www.w3.org/Addressing/") +
-	"\",\nbut you can probably pick it up from <" + style.Link("ftp://foo.example.com/rfc/") +
-	">.\nNote the warning in <" + style.Link("http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING") + ">.", 50)
-
-	util.AssertEqual(expected, output, t)
-}
+package plaintext
+
+import (
+	"mimicry/ansi"
+	"mimicry/style"
+	"mimicry/util"
+	"testing"
+)
+
+func TestBasic(t *testing.T) {
+	input := `Yes, Jim, I found it under "http://www.w3.org/Addressing/",
+but you can probably pick it up from <ftp://foo.example.com/rfc/>.
+Note the warning in <http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING>.`
+	output, err := Render(input, 50)
+	if err != nil {
+		panic(err)
+	}
+
+	expected := ansi.Wrap("Yes, Jim, I found it under \""+style.Link("http://www.w3.org/Addressing/")+
+		"\",\nbut you can probably pick it up from <"+style.Link("ftp://foo.example.com/rfc/")+
+		">.\nNote the warning in <"+style.Link("http://www.ics.uci.edu/pub/ietf/uri/historical.html#WARNING")+">.", 50)
+
+	util.AssertEqual(expected, output, t)
+}

+ 127 - 123
pub/activity.go

@@ -1,123 +1,127 @@
-package pub
-
-import (
-	"net/url"
-	"mimicry/object"
-	"mimicry/client"
-	"fmt"
-	"golang.org/x/exp/slices"
-	"mimicry/ansi"
-	"mimicry/style"
-	"sync"
-	"time"
-	"errors"
-)
-
-type Activity struct {
-	kind string
-	id *url.URL
-
-	actor *Actor; actorErr error
-	created time.Time; createdErr error
-	target Tangible
-}
-
-func NewActivity(input any, source *url.URL) (*Activity, error) {
-	o, id, err := client.FetchUnknown(input, source)
-	if err != nil { return nil, err }
-	return NewActivityFromObject(o, id)
-}
-
-func NewActivityFromObject(o object.Object, id *url.URL) (*Activity, error) {
-	a := &Activity{}
-	a.id = id
-	var err error
-	if a.kind, err = o.GetString("type"); err != nil {
-		return nil, err
-	}
-
-	if !slices.Contains([]string{
-		"Create", "Announce", "Dislike", "Like",
-	}, a.kind) {
-		return nil, fmt.Errorf("%w: %s is not an Activity", ErrWrongType, a.kind)
-	}
-
-	a.created, a.createdErr = o.GetTime("published")
-
-	var wg sync.WaitGroup
-	wg.Add(2)
-	go func () {a.actor, a.actorErr = getActor(o, "actor", a.id); wg.Done()}()
-	go func() {a.target = getPostOrActor(o, "object", a.id); wg.Done()}()
-	wg.Wait()
-
-	return a, nil
-}
-
-func (a *Activity) Kind() string {
-	return a.kind
-}
-
-func (a *Activity) header(width int) string {
-	if a.kind == "Create" {
-		return ""
-	}
-
-	var output string
-	if a.actorErr != nil {
-		output += style.Problem(a.actorErr)
-	} else {
-		output += a.actor.Name()
-	}
-
-	output += " "
-
-	switch a.kind {
-	case "Announce":
-		output += "retweeted"
-	case "Like":
-		output += "upvoted"
-	case "Dislike":
-		output += "downvoted"
-	default:
-		panic("encountered unrecognized Actor type: " + a.kind)
-	}
-
-	output += ":\n"
-
-	return ansi.Wrap(output, width)
-}
-
-func (a *Activity) String(width int) string {
-	output := a.header(width)
-
-	output += a.target.String(width)
-	return output
-}
-
-func (a *Activity) Preview(width int) string {
-	output := a.header(width)
-
-	output += a.target.Preview(width)
-	return output
-}
-
-func (a *Activity) Children() Container {
-	return a.target.Children()
-}
-
-func (a *Activity) Parents(quantity uint) ([]Tangible, Tangible) {
-	return a.target.Parents(quantity)
-}
-
-func (a *Activity) Timestamp() time.Time {
-	if errors.Is(a.createdErr, object.ErrKeyNotPresent) {
-		if a.kind == "Create" {
-			return a.target.Timestamp()
-		} else {
-			return time.Time{}
-		}
-	} else if a.createdErr != nil {
-		return time.Time{}
-	}
-	return a.created
-}
+package pub
+
+import (
+	"errors"
+	"fmt"
+	"golang.org/x/exp/slices"
+	"mimicry/ansi"
+	"mimicry/client"
+	"mimicry/object"
+	"mimicry/style"
+	"net/url"
+	"sync"
+	"time"
+)
+
+type Activity struct {
+	kind string
+	id   *url.URL
+
+	actor      *Actor
+	actorErr   error
+	created    time.Time
+	createdErr error
+	target     Tangible
+}
+
+func NewActivity(input any, source *url.URL) (*Activity, error) {
+	o, id, err := client.FetchUnknown(input, source)
+	if err != nil {
+		return nil, err
+	}
+	return NewActivityFromObject(o, id)
+}
+
+func NewActivityFromObject(o object.Object, id *url.URL) (*Activity, error) {
+	a := &Activity{}
+	a.id = id
+	var err error
+	if a.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
+
+	if !slices.Contains([]string{
+		"Create", "Announce", "Dislike", "Like",
+	}, a.kind) {
+		return nil, fmt.Errorf("%w: %s is not an Activity", ErrWrongType, a.kind)
+	}
+
+	a.created, a.createdErr = o.GetTime("published")
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() { a.actor, a.actorErr = getActor(o, "actor", a.id); wg.Done() }()
+	go func() { a.target = getPostOrActor(o, "object", a.id); wg.Done() }()
+	wg.Wait()
+
+	return a, nil
+}
+
+func (a *Activity) Kind() string {
+	return a.kind
+}
+
+func (a *Activity) header(width int) string {
+	if a.kind == "Create" {
+		return ""
+	}
+
+	var output string
+	if a.actorErr != nil {
+		output += style.Problem(a.actorErr)
+	} else {
+		output += a.actor.Name()
+	}
+
+	output += " "
+
+	switch a.kind {
+	case "Announce":
+		output += "retweeted"
+	case "Like":
+		output += "upvoted"
+	case "Dislike":
+		output += "downvoted"
+	default:
+		panic("encountered unrecognized Actor type: " + a.kind)
+	}
+
+	output += ":\n"
+
+	return ansi.Wrap(output, width)
+}
+
+func (a *Activity) String(width int) string {
+	output := a.header(width)
+
+	output += a.target.String(width)
+	return output
+}
+
+func (a *Activity) Preview(width int) string {
+	output := a.header(width)
+
+	output += a.target.Preview(width)
+	return output
+}
+
+func (a *Activity) Children() Container {
+	return a.target.Children()
+}
+
+func (a *Activity) Parents(quantity uint) ([]Tangible, Tangible) {
+	return a.target.Parents(quantity)
+}
+
+func (a *Activity) Timestamp() time.Time {
+	if errors.Is(a.createdErr, object.ErrKeyNotPresent) {
+		if a.kind == "Create" {
+			return a.target.Timestamp()
+		} else {
+			return time.Time{}
+		}
+	} else if a.createdErr != nil {
+		return time.Time{}
+	}
+	return a.created
+}

+ 220 - 206
pub/actor.go

@@ -1,206 +1,220 @@
-package pub
-
-import (
-	"net/url"
-	"mimicry/style"
-	"errors"
-	"mimicry/object"
-	"time"
-	"mimicry/client"
-	"golang.org/x/exp/slices"
-	"fmt"
-	"strings"
-	"mimicry/ansi"
-	"mimicry/mime"
-	"mimicry/render"
-)
-
-type Actor struct {
-	kind string
-	name string; nameErr error
-	handle string; handleErr error
-
-	id *url.URL
-
-	bio string; bioErr error
-	mediaType *mime.MediaType; mediaTypeErr error
-
-	joined time.Time; joinedErr error
-
-	pfp *Link; pfpErr error
-	banner *Link; bannerErr error
-
-	posts *Collection; postsErr error
-}
-
-func NewActor(input any, source *url.URL) (*Actor, error) {
-	o, id, err := client.FetchUnknown(input, source)
-	if err != nil { return nil, err }
-	return NewActorFromObject(o, id)
-}
-
-func NewActorFromObject(o object.Object, id *url.URL) (*Actor, error) {
-	a := &Actor{}
-	a.id = id
-	var err error
-	if a.kind, err = o.GetString("type"); err != nil {
-		return nil, err
-	}
-
-	if !slices.Contains([]string{
-		"Application", "Group", "Organization", "Person", "Service",
-	}, a.kind) {
-		return nil, fmt.Errorf("%w: %s is not an Actor", ErrWrongType, a.kind)
-	}
-
-	a.name, a.nameErr = o.GetNatural("name", "en")
-	a.handle, a.handleErr = o.GetString("preferredUsername")
-	a.bio, a.bioErr = o.GetNatural("summary", "en")
-	if a.bio == "" {
-		a.bioErr = object.ErrKeyNotPresent
-	}
-	a.mediaType, a.mediaTypeErr = o.GetMediaType("mediaType")
-	a.joined, a.joinedErr = o.GetTime("published")
-
-	a.pfp, a.pfpErr = getBestLink(o, "icon", "image")
-	a.banner, a.bannerErr = getBestLink(o, "image", "image")
-	
-	a.posts, a.postsErr = getCollection(o, "outbox", a.id)
-	return a, nil
-}
-
-func (a *Actor) Kind() string {
-	return a.kind
-}
-
-func (a *Actor) Parents(quantity uint) ([]Tangible, Tangible) {
-	return []Tangible{}, nil
-}
-
-func (a *Actor) Children() Container {
-	/* the if is necessary because my understanding is
-	   the first nil is a (*Collection)(nil) whereas
-	   the second is (Container)(nil) */
-	if a.posts == nil {
-		return nil
-	} else {
-		return a.posts
-	}
-}
-
-// TODO: here is where I'd put forgery errors in
-func (a *Actor) Name() string {
-	var output string
-	if a.nameErr == nil {
-		output = a.name
-	} else if !errors.Is(a.nameErr, object.ErrKeyNotPresent) {
-		output = style.Problem(a.nameErr)
-	}
-
-	if a.id != nil && !errors.Is(a.handleErr, object.ErrKeyNotPresent) {
-		if output != "" { output += " " }
-		if a.handleErr != nil {
-			output += style.Problem(a.handleErr)
-		} else {
-			output += style.Italic("@" + a.handle + "@" + a.id.Host)
-		}
-	}
-
-	if a.kind != "Person" {
-		if output != "" { output += " " }
-		output += "(" + strings.ToLower(a.kind) + ")"
-	} else if output == "" {
-		output = strings.ToLower(a.kind)
-	}
-
-	return style.Color(output)
-}
-
-func (a *Actor) header(width int) string {
-	output := a.Name()
-
-	if errors.Is(a.joinedErr, object.ErrKeyNotPresent) {
-		// omit it
-	} else if a.joinedErr != nil {
-		output += "\njoined " + style.Problem(a.joinedErr)
-	} else {
-		output += "\njoined " + style.Color(a.joined.Format("2 Jan 2006"))
-	}
-
-	return ansi.Wrap(output, width)
-}
-
-func (a *Actor) center(width int) (string, bool) {
-	if errors.Is(a.bioErr, object.ErrKeyNotPresent) {
-		return "", false
-	}
-	if a.bioErr != nil {
-		return ansi.Wrap(style.Problem(a.bioErr), width), true
-	}
-
-	mediaType := a.mediaType
-	if errors.Is(a.mediaTypeErr, object.ErrKeyNotPresent) {
-		mediaType = mime.Default()
-	} else if a.mediaTypeErr != nil {
-		return ansi.Wrap(style.Problem(a.mediaTypeErr), width), true
-	}
-
-	rendered, err := render.Render(a.bio, mediaType.Essence, width)
-	if err != nil {
-		return style.Problem(err), true
-	}
-	return rendered, true
-}
-
-func (a *Actor) footer(width int) (string, bool) {
-	if errors.Is(a.postsErr, object.ErrKeyNotPresent) {
-		return style.Problem(a.postsErr), true
-	} else if a.postsErr != nil {
-		return "", false
-	} else if quantity, err := a.posts.Size(); errors.Is(err, object.ErrKeyNotPresent) {
-		return "", false
-	} else if err != nil {
-		return style.Problem(err), true
-	} else if quantity == 1 {
-		return style.Color(fmt.Sprintf("%d post", quantity)), true
-	} else {
-		return style.Color(fmt.Sprintf("%d posts", quantity)), true
-	}
-}
-
-func (a *Actor) String(width int) string {
-	output := a.header(width)
-
-	if body, present := a.center(width - 4); present {
-		output += "\n\n" + ansi.Indent(body, "  ", true) + "\n"
-	}
-
-	if footer, present := a.footer(width); present {
-		output += "\n" + footer
-	}
-
-	return output
-}
-
-func (a Actor) Preview(width int) string {
-	output := a.header(width)
-
-	// TODO this needs to be truncated
-	if body, present := a.center(width); present {
-		output += "\n" + ansi.Snip(body, width, 4, style.Color("\u2026"))
-	}
-
-	if footer, present := a.footer(width); present {
-		output += "\n" + footer
-	}
-
-	return output
-}
-
-func (a *Actor) Timestamp() time.Time {
-	if a.joinedErr != nil {
-		return time.Time{}
-	} else {
-		return a.joined
-	}
-}
+package pub
+
+import (
+	"errors"
+	"fmt"
+	"golang.org/x/exp/slices"
+	"mimicry/ansi"
+	"mimicry/client"
+	"mimicry/mime"
+	"mimicry/object"
+	"mimicry/render"
+	"mimicry/style"
+	"net/url"
+	"strings"
+	"time"
+)
+
+type Actor struct {
+	kind      string
+	name      string
+	nameErr   error
+	handle    string
+	handleErr error
+
+	id *url.URL
+
+	bio          string
+	bioErr       error
+	mediaType    *mime.MediaType
+	mediaTypeErr error
+
+	joined    time.Time
+	joinedErr error
+
+	pfp       *Link
+	pfpErr    error
+	banner    *Link
+	bannerErr error
+
+	posts    *Collection
+	postsErr error
+}
+
+func NewActor(input any, source *url.URL) (*Actor, error) {
+	o, id, err := client.FetchUnknown(input, source)
+	if err != nil {
+		return nil, err
+	}
+	return NewActorFromObject(o, id)
+}
+
+func NewActorFromObject(o object.Object, id *url.URL) (*Actor, error) {
+	a := &Actor{}
+	a.id = id
+	var err error
+	if a.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
+
+	if !slices.Contains([]string{
+		"Application", "Group", "Organization", "Person", "Service",
+	}, a.kind) {
+		return nil, fmt.Errorf("%w: %s is not an Actor", ErrWrongType, a.kind)
+	}
+
+	a.name, a.nameErr = o.GetNatural("name", "en")
+	a.handle, a.handleErr = o.GetString("preferredUsername")
+	a.bio, a.bioErr = o.GetNatural("summary", "en")
+	if a.bio == "" {
+		a.bioErr = object.ErrKeyNotPresent
+	}
+	a.mediaType, a.mediaTypeErr = o.GetMediaType("mediaType")
+	a.joined, a.joinedErr = o.GetTime("published")
+
+	a.pfp, a.pfpErr = getBestLink(o, "icon", "image")
+	a.banner, a.bannerErr = getBestLink(o, "image", "image")
+
+	a.posts, a.postsErr = getCollection(o, "outbox", a.id)
+	return a, nil
+}
+
+func (a *Actor) Kind() string {
+	return a.kind
+}
+
+func (a *Actor) Parents(quantity uint) ([]Tangible, Tangible) {
+	return []Tangible{}, nil
+}
+
+func (a *Actor) Children() Container {
+	/* the if is necessary because my understanding is
+	   the first nil is a (*Collection)(nil) whereas
+	   the second is (Container)(nil) */
+	if a.posts == nil {
+		return nil
+	} else {
+		return a.posts
+	}
+}
+
+// TODO: here is where I'd put forgery errors in
+func (a *Actor) Name() string {
+	var output string
+	if a.nameErr == nil {
+		output = a.name
+	} else if !errors.Is(a.nameErr, object.ErrKeyNotPresent) {
+		output = style.Problem(a.nameErr)
+	}
+
+	if a.id != nil && !errors.Is(a.handleErr, object.ErrKeyNotPresent) {
+		if output != "" {
+			output += " "
+		}
+		if a.handleErr != nil {
+			output += style.Problem(a.handleErr)
+		} else {
+			output += style.Italic("@" + a.handle + "@" + a.id.Host)
+		}
+	}
+
+	if a.kind != "Person" {
+		if output != "" {
+			output += " "
+		}
+		output += "(" + strings.ToLower(a.kind) + ")"
+	} else if output == "" {
+		output = strings.ToLower(a.kind)
+	}
+
+	return style.Color(output)
+}
+
+func (a *Actor) header(width int) string {
+	output := a.Name()
+
+	if errors.Is(a.joinedErr, object.ErrKeyNotPresent) {
+		// omit it
+	} else if a.joinedErr != nil {
+		output += "\njoined " + style.Problem(a.joinedErr)
+	} else {
+		output += "\njoined " + style.Color(a.joined.Format("2 Jan 2006"))
+	}
+
+	return ansi.Wrap(output, width)
+}
+
+func (a *Actor) center(width int) (string, bool) {
+	if errors.Is(a.bioErr, object.ErrKeyNotPresent) {
+		return "", false
+	}
+	if a.bioErr != nil {
+		return ansi.Wrap(style.Problem(a.bioErr), width), true
+	}
+
+	mediaType := a.mediaType
+	if errors.Is(a.mediaTypeErr, object.ErrKeyNotPresent) {
+		mediaType = mime.Default()
+	} else if a.mediaTypeErr != nil {
+		return ansi.Wrap(style.Problem(a.mediaTypeErr), width), true
+	}
+
+	rendered, err := render.Render(a.bio, mediaType.Essence, width)
+	if err != nil {
+		return style.Problem(err), true
+	}
+	return rendered, true
+}
+
+func (a *Actor) footer(width int) (string, bool) {
+	if errors.Is(a.postsErr, object.ErrKeyNotPresent) {
+		return style.Problem(a.postsErr), true
+	} else if a.postsErr != nil {
+		return "", false
+	} else if quantity, err := a.posts.Size(); errors.Is(err, object.ErrKeyNotPresent) {
+		return "", false
+	} else if err != nil {
+		return style.Problem(err), true
+	} else if quantity == 1 {
+		return style.Color(fmt.Sprintf("%d post", quantity)), true
+	} else {
+		return style.Color(fmt.Sprintf("%d posts", quantity)), true
+	}
+}
+
+func (a *Actor) String(width int) string {
+	output := a.header(width)
+
+	if body, present := a.center(width - 4); present {
+		output += "\n\n" + ansi.Indent(body, "  ", true) + "\n"
+	}
+
+	if footer, present := a.footer(width); present {
+		output += "\n" + footer
+	}
+
+	return output
+}
+
+func (a Actor) Preview(width int) string {
+	output := a.header(width)
+
+	// TODO this needs to be truncated
+	if body, present := a.center(width); present {
+		output += "\n" + ansi.Snip(body, width, 4, style.Color("\u2026"))
+	}
+
+	if footer, present := a.footer(width); present {
+		output += "\n" + footer
+	}
+
+	return output
+}
+
+func (a *Actor) Timestamp() time.Time {
+	if a.joinedErr != nil {
+		return time.Time{}
+	} else {
+		return a.joined
+	}
+}

+ 149 - 143
pub/collection.go

@@ -1,143 +1,149 @@
-package pub
-
-import (
-	"net/url"
-	"mimicry/object"
-	"errors"
-	"mimicry/client"
-	"fmt"
-	"golang.org/x/exp/slices"
-	"sync"
-)
-
-/*
-	Methods are:
-	Category
-	Kind
-	Identifier
-	Next
-	Size
-	Items (returns list)
-	String // maybe just show this page, and Next can be a button
-		// the infiniscroll will be provided by the View package
-*/
-
-// Should probably take in a constructor, actor gives NewActivity
-// and Post gives NewPost, but not exactly, they can wrap them
-// in a function which also checks whether the element is
-// valid in the given context
-
-type Collection struct {
-	kind string
-	id *url.URL
-
-	elements []any; elementsErr error
-	next any; nextErr error
-
-	size uint64; sizeErr error
-}
-
-func NewCollection(input any, source *url.URL) (*Collection, error) {
-	c := &Collection{}
-	var o object.Object; var err error
-	o, c.id, err = client.FetchUnknown(input, source)
-	if err != nil { return nil, err }
-	if c.kind, err = o.GetString("type"); err != nil {
-		return nil, err
-	}
-
-	if !slices.Contains([]string{
-		"Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage",
-	}, c.kind) {
-		return nil, fmt.Errorf("%w: %s is not a Collection", ErrWrongType, c.kind)
-	}
-
-	if c.kind == "Collection" || c.kind == "CollectionPage" {
-		c.elements, c.elementsErr = o.GetList("items")
-	} else {
-		c.elements, c.elementsErr = o.GetList("orderedItems")
-	}
-
-	if c.kind == "Collection" || c.kind == "OrderedCollection" {
-		c.next, c.nextErr = o.GetAny("first")
-	} else {
-		c.next, c.nextErr = o.GetAny("next")
-	}
-
-	c.size, c.sizeErr = o.GetNumber("totalItems")
-
-	return c, nil
-}
-
-func (c *Collection) Kind() string {
-	return c.kind
-}
-
-func (c *Collection) Size() (uint64, error) {
-	return c.size, c.sizeErr
-}
-
-func (c *Collection) Harvest(amount uint, startingPoint uint) ([]Tangible, Container, uint) {
-	if c == nil {
-		panic("can't harvest nil collection")
-	}
-
-	if c.elementsErr != nil && !errors.Is(c.elementsErr, object.ErrKeyNotPresent) {
-		return []Tangible{NewFailure(c.elementsErr)}, nil, 0
-	}
-
-	var length uint
-	if errors.Is(c.elementsErr, object.ErrKeyNotPresent) {
-		length = 0
-	} else {
-		length = uint(len(c.elements))
-	}
-
-	// TODO: change to bool nextWillBeFetched in which case amount from this page is all
-	// and later on the variable is clear
-
-	var amountFromThisPage uint
-	if startingPoint >= length {
-		amountFromThisPage = 0
-	} else if length > amount + startingPoint {
-		amountFromThisPage = amount
-	} else {
-		amountFromThisPage = length - startingPoint
-	}
-
-	fromThisPage := make([]Tangible, amountFromThisPage)
-	var fromLaterPages []Tangible
-	var nextCollection Container
-	var nextStartingPoint uint
-
-	var wg sync.WaitGroup
-	for i := uint(0); i < amountFromThisPage; i++ {
-		i := i
-		wg.Add(1)
-		go func() {
-			fromThisPage[i] = NewTangible(c.elements[i+startingPoint], c.id)
-			wg.Done()
-		}()
-	}
-
-	wg.Add(1)
-	go func() {
-		if length > amount + startingPoint {
-			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{}, c, amount + startingPoint
-		} else if errors.Is(c.nextErr, object.ErrKeyNotPresent) {
-			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{}, nil, 0
-		} else if c.nextErr != nil {
-			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{NewFailure(c.nextErr)}, nil, 0
-		} else if next, err := NewCollection(c.next, c.id); err != nil {
-			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{NewFailure(err)}, nil, 0
-		} else {
-			fromLaterPages, nextCollection, nextStartingPoint = next.Harvest(amount - amountFromThisPage, 0)
-		}
-
-		wg.Done()
-	}()
-
-	wg.Wait()
-
-	return append(fromThisPage, fromLaterPages...), nextCollection, nextStartingPoint
-}
+package pub
+
+import (
+	"errors"
+	"fmt"
+	"golang.org/x/exp/slices"
+	"mimicry/client"
+	"mimicry/object"
+	"net/url"
+	"sync"
+)
+
+/*
+	Methods are:
+	Category
+	Kind
+	Identifier
+	Next
+	Size
+	Items (returns list)
+	String // maybe just show this page, and Next can be a button
+		// the infiniscroll will be provided by the View package
+*/
+
+// Should probably take in a constructor, actor gives NewActivity
+// and Post gives NewPost, but not exactly, they can wrap them
+// in a function which also checks whether the element is
+// valid in the given context
+
+type Collection struct {
+	kind string
+	id   *url.URL
+
+	elements    []any
+	elementsErr error
+	next        any
+	nextErr     error
+
+	size    uint64
+	sizeErr error
+}
+
+func NewCollection(input any, source *url.URL) (*Collection, error) {
+	c := &Collection{}
+	var o object.Object
+	var err error
+	o, c.id, err = client.FetchUnknown(input, source)
+	if err != nil {
+		return nil, err
+	}
+	if c.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
+
+	if !slices.Contains([]string{
+		"Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage",
+	}, c.kind) {
+		return nil, fmt.Errorf("%w: %s is not a Collection", ErrWrongType, c.kind)
+	}
+
+	if c.kind == "Collection" || c.kind == "CollectionPage" {
+		c.elements, c.elementsErr = o.GetList("items")
+	} else {
+		c.elements, c.elementsErr = o.GetList("orderedItems")
+	}
+
+	if c.kind == "Collection" || c.kind == "OrderedCollection" {
+		c.next, c.nextErr = o.GetAny("first")
+	} else {
+		c.next, c.nextErr = o.GetAny("next")
+	}
+
+	c.size, c.sizeErr = o.GetNumber("totalItems")
+
+	return c, nil
+}
+
+func (c *Collection) Kind() string {
+	return c.kind
+}
+
+func (c *Collection) Size() (uint64, error) {
+	return c.size, c.sizeErr
+}
+
+func (c *Collection) Harvest(amount uint, startingPoint uint) ([]Tangible, Container, uint) {
+	if c == nil {
+		panic("can't harvest nil collection")
+	}
+
+	if c.elementsErr != nil && !errors.Is(c.elementsErr, object.ErrKeyNotPresent) {
+		return []Tangible{NewFailure(c.elementsErr)}, nil, 0
+	}
+
+	var length uint
+	if errors.Is(c.elementsErr, object.ErrKeyNotPresent) {
+		length = 0
+	} else {
+		length = uint(len(c.elements))
+	}
+
+	// TODO: change to bool nextWillBeFetched in which case amount from this page is all
+	// and later on the variable is clear
+
+	var amountFromThisPage uint
+	if startingPoint >= length {
+		amountFromThisPage = 0
+	} else if length > amount+startingPoint {
+		amountFromThisPage = amount
+	} else {
+		amountFromThisPage = length - startingPoint
+	}
+
+	fromThisPage := make([]Tangible, amountFromThisPage)
+	var fromLaterPages []Tangible
+	var nextCollection Container
+	var nextStartingPoint uint
+
+	var wg sync.WaitGroup
+	for i := uint(0); i < amountFromThisPage; i++ {
+		i := i
+		wg.Add(1)
+		go func() {
+			fromThisPage[i] = NewTangible(c.elements[i+startingPoint], c.id)
+			wg.Done()
+		}()
+	}
+
+	wg.Add(1)
+	go func() {
+		if length > amount+startingPoint {
+			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{}, c, amount+startingPoint
+		} else if errors.Is(c.nextErr, object.ErrKeyNotPresent) {
+			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{}, nil, 0
+		} else if c.nextErr != nil {
+			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{NewFailure(c.nextErr)}, nil, 0
+		} else if next, err := NewCollection(c.next, c.id); err != nil {
+			fromLaterPages, nextCollection, nextStartingPoint = []Tangible{NewFailure(err)}, nil, 0
+		} else {
+			fromLaterPages, nextCollection, nextStartingPoint = next.Harvest(amount-amountFromThisPage, 0)
+		}
+
+		wg.Done()
+	}()
+
+	wg.Wait()
+
+	return append(fromThisPage, fromLaterPages...), nextCollection, nextStartingPoint
+}

+ 214 - 207
pub/common.go

@@ -1,207 +1,214 @@
-package pub
-
-import (
-	"mimicry/object"
-	"fmt"
-	"errors"
-	"net/url"
-	"mimicry/client"
-	"sync"
-)
-
-var (
-	ErrWrongType = errors.New("item is the wrong type")
-)
-
-type TangibleWithName interface {
-	Tangible
-	Name() string
-}
-func getActors(o object.Object, key string, source *url.URL) []TangibleWithName {
-	list, err := o.GetList(key)
-	if errors.Is(err, object.ErrKeyNotPresent) {
-		return []TangibleWithName{}
-	} else if err != nil {
-		return []TangibleWithName{NewFailure(err)}
-	}
-
-	output := make([]TangibleWithName, len(list))
-	var wg sync.WaitGroup
-	for i := range list {
-		wg.Add(1)
-		i := i
-		go func() {
-			fetched, err := NewActor(list[i], source)
-			if err != nil {
-				output[i] = NewFailure(err)
-			} else {
-				output[i] = fetched
-			}
-			wg.Done()
-		}()
-	}
-	wg.Wait()
-	return output
-}
-
-func getPostOrActor(o object.Object, key string, source *url.URL) Tangible {
-	reference, err := o.GetAny(key)
-	if err != nil {
-		return NewFailure(err)
-	}
-
-	/* For Lemmy compatibility, automatically unwrap if the entry is an
-	   inline Create type */
-	if asMap, ok := reference.(map[string]any); ok {
-		o := object.Object(asMap)
-		kind, err := o.GetString("type")
-		if err != nil { return NewFailure(err) }
-		if kind == "Create" {
-			reference, err = o.GetAny("object")
-			if err != nil { return NewFailure(err) }
-		}
-	}
-
-	o, id, err := client.FetchUnknown(reference, source)
-	if err != nil {
-		return NewFailure(err)
-	}
-
-	var fetched Tangible
-	var postErr, actorErr error
-	fetched, postErr = NewPostFromObject(o, id)
-	if errors.Is(postErr, ErrWrongType) {
-		fetched, actorErr = NewActorFromObject(o, id)
-		if errors.Is(actorErr, ErrWrongType) {
-			return NewFailure(fmt.Errorf("%w, %w", postErr, actorErr))
-		} else if actorErr != nil {
-			return NewFailure(actorErr)
-		}
-	} else if postErr != nil {
-		return NewFailure(postErr)
-	}
-
-	return fetched
-}
-
-func getCollection(o object.Object, key string, source *url.URL) (*Collection, error) {
-	reference, err := o.GetAny(key)
-	if err != nil {
-		return nil, err
-	}
-
-	fetched, err := NewCollection(reference, source)
-	if err != nil {
-		return nil, err
-	}
-	return fetched, nil
-}
-
-func getActor(o object.Object, key string, source *url.URL) (*Actor, error) {
-	reference, err := o.GetAny(key)
-	if err != nil {
-		return nil, err
-	}
-
-	fetched, err := NewActor(reference, source)
-	if err != nil {
-		return nil, err
-	}
-	return fetched, nil
-}
-
-func NewTangible(input any, source *url.URL) Tangible {
-	var fetched Tangible
-	fetched, err := NewPost(input, source)
-
-	if errors.Is(err, ErrWrongType) {
-		fetched, err = NewActor(input, source)
-	}
-
-	if errors.Is(err, ErrWrongType) {
-		fetched, err = NewActivity(input, source)
-	}
-
-	if errors.Is(err, ErrWrongType) {
-		return NewFailure(err)
-	}
-
-	if err != nil {
-		return NewFailure(err)
-	}
-
-	return fetched
-}
-
-/*
-	"Shorthand" just means individual strings are converted into Links
-*/
-func getLinksShorthand(o object.Object, key string) ([]*Link, error) {
-	list, err := o.GetList(key)
-	if err != nil {
-		return nil, err
-	}
-
-	output := make([]*Link, len(list))
-
-	for i, element := range list {
-		switch narrowed := element.(type) {
-		case object.Object:
-			link, err := NewLink(narrowed)
-			if err != nil {
-				return nil, err
-			}
-			output[i] = link
-		case string:
-			link, err := NewLink(object.Object {
-				"type": "Link",
-				"href": narrowed,
-			})
-			if err != nil {
-				return nil, err
-			}
-			output[i] = link
-		default:
-			return nil, fmt.Errorf("can't convert a %T into a Link", element)
-		}
-	}
-	return output, nil
-}
-
-func getBestLinkShorthand(o object.Object, key string, supertype string) (*Link, error) {
-	links, err := getLinksShorthand(o, key)
-	if err != nil {
-		return nil, err
-	}
-	return SelectBestLink(links, supertype)
-}
-
-func getFirstLinkShorthand(o object.Object, key string) (*Link, error) {
-	links, err := getLinksShorthand(o, key)
-	if err != nil {
-		return nil, err
-	}
-	return SelectFirstLink(links)
-}
-
-func getLinks(o object.Object, key string) ([]*Link, error) {
-	list, err := o.GetList(key)
-	if err != nil {
-		return nil, err
-	}
-	links := make([]*Link, len(list))
-	for i, element := range list {
-		link, err := NewLink(element)
-		if err != nil {
-			return nil, err
-		}
-		links[i] = link
-	}
-	return links, nil
-}
-
-func getBestLink(o object.Object, key string, supertype string) (*Link, error) {
-	links, err := getLinks(o, key)
-	if err != nil { return nil, err }
-	return SelectBestLink(links, supertype)
-}
+package pub
+
+import (
+	"errors"
+	"fmt"
+	"mimicry/client"
+	"mimicry/object"
+	"net/url"
+	"sync"
+)
+
+var (
+	ErrWrongType = errors.New("item is the wrong type")
+)
+
+type TangibleWithName interface {
+	Tangible
+	Name() string
+}
+
+func getActors(o object.Object, key string, source *url.URL) []TangibleWithName {
+	list, err := o.GetList(key)
+	if errors.Is(err, object.ErrKeyNotPresent) {
+		return []TangibleWithName{}
+	} else if err != nil {
+		return []TangibleWithName{NewFailure(err)}
+	}
+
+	output := make([]TangibleWithName, len(list))
+	var wg sync.WaitGroup
+	for i := range list {
+		wg.Add(1)
+		i := i
+		go func() {
+			fetched, err := NewActor(list[i], source)
+			if err != nil {
+				output[i] = NewFailure(err)
+			} else {
+				output[i] = fetched
+			}
+			wg.Done()
+		}()
+	}
+	wg.Wait()
+	return output
+}
+
+func getPostOrActor(o object.Object, key string, source *url.URL) Tangible {
+	reference, err := o.GetAny(key)
+	if err != nil {
+		return NewFailure(err)
+	}
+
+	/* For Lemmy compatibility, automatically unwrap if the entry is an
+	   inline Create type */
+	if asMap, ok := reference.(map[string]any); ok {
+		o := object.Object(asMap)
+		kind, err := o.GetString("type")
+		if err != nil {
+			return NewFailure(err)
+		}
+		if kind == "Create" {
+			reference, err = o.GetAny("object")
+			if err != nil {
+				return NewFailure(err)
+			}
+		}
+	}
+
+	o, id, err := client.FetchUnknown(reference, source)
+	if err != nil {
+		return NewFailure(err)
+	}
+
+	var fetched Tangible
+	var postErr, actorErr error
+	fetched, postErr = NewPostFromObject(o, id)
+	if errors.Is(postErr, ErrWrongType) {
+		fetched, actorErr = NewActorFromObject(o, id)
+		if errors.Is(actorErr, ErrWrongType) {
+			return NewFailure(fmt.Errorf("%w, %w", postErr, actorErr))
+		} else if actorErr != nil {
+			return NewFailure(actorErr)
+		}
+	} else if postErr != nil {
+		return NewFailure(postErr)
+	}
+
+	return fetched
+}
+
+func getCollection(o object.Object, key string, source *url.URL) (*Collection, error) {
+	reference, err := o.GetAny(key)
+	if err != nil {
+		return nil, err
+	}
+
+	fetched, err := NewCollection(reference, source)
+	if err != nil {
+		return nil, err
+	}
+	return fetched, nil
+}
+
+func getActor(o object.Object, key string, source *url.URL) (*Actor, error) {
+	reference, err := o.GetAny(key)
+	if err != nil {
+		return nil, err
+	}
+
+	fetched, err := NewActor(reference, source)
+	if err != nil {
+		return nil, err
+	}
+	return fetched, nil
+}
+
+func NewTangible(input any, source *url.URL) Tangible {
+	var fetched Tangible
+	fetched, err := NewPost(input, source)
+
+	if errors.Is(err, ErrWrongType) {
+		fetched, err = NewActor(input, source)
+	}
+
+	if errors.Is(err, ErrWrongType) {
+		fetched, err = NewActivity(input, source)
+	}
+
+	if errors.Is(err, ErrWrongType) {
+		return NewFailure(err)
+	}
+
+	if err != nil {
+		return NewFailure(err)
+	}
+
+	return fetched
+}
+
+/*
+"Shorthand" just means individual strings are converted into Links
+*/
+func getLinksShorthand(o object.Object, key string) ([]*Link, error) {
+	list, err := o.GetList(key)
+	if err != nil {
+		return nil, err
+	}
+
+	output := make([]*Link, len(list))
+
+	for i, element := range list {
+		switch narrowed := element.(type) {
+		case object.Object:
+			link, err := NewLink(narrowed)
+			if err != nil {
+				return nil, err
+			}
+			output[i] = link
+		case string:
+			link, err := NewLink(object.Object{
+				"type": "Link",
+				"href": narrowed,
+			})
+			if err != nil {
+				return nil, err
+			}
+			output[i] = link
+		default:
+			return nil, fmt.Errorf("can't convert a %T into a Link", element)
+		}
+	}
+	return output, nil
+}
+
+func getBestLinkShorthand(o object.Object, key string, supertype string) (*Link, error) {
+	links, err := getLinksShorthand(o, key)
+	if err != nil {
+		return nil, err
+	}
+	return SelectBestLink(links, supertype)
+}
+
+func getFirstLinkShorthand(o object.Object, key string) (*Link, error) {
+	links, err := getLinksShorthand(o, key)
+	if err != nil {
+		return nil, err
+	}
+	return SelectFirstLink(links)
+}
+
+func getLinks(o object.Object, key string) ([]*Link, error) {
+	list, err := o.GetList(key)
+	if err != nil {
+		return nil, err
+	}
+	links := make([]*Link, len(list))
+	for i, element := range list {
+		link, err := NewLink(element)
+		if err != nil {
+			return nil, err
+		}
+		links[i] = link
+	}
+	return links, nil
+}
+
+func getBestLink(o object.Object, key string, supertype string) (*Link, error) {
+	links, err := getLinks(o, key)
+	if err != nil {
+		return nil, err
+	}
+	return SelectBestLink(links, supertype)
+}

+ 43 - 43
pub/failure.go

@@ -1,43 +1,43 @@
-package pub
-
-import (
-	"mimicry/style"
-	"time"
-)
-
-type Failure struct {
-	message error
-}
-
-func NewFailure(err error) *Failure {
-	if err == nil {
-		panic("do not create a failure with a nil error")
-	}
-	return &Failure{err}
-}
-
-func (f *Failure) Kind() string { return "failure" }
-
-func (f *Failure) Name() string {
-	return style.Problem(f.message)
-}
-
-func (f *Failure) Preview(width int) string {
-	return f.Name()
-}
-
-func (f *Failure) String(width int) string {
-	return f.Preview(width)
-}
-
-func (f *Failure) Parents(uint) ([]Tangible, Tangible) {
-	return []Tangible{}, nil
-}
-
-func (f *Failure) Children() Container {
-	return nil
-}
-
-func (f *Failure) Timestamp() time.Time {
-	return time.Time{}
-}
+package pub
+
+import (
+	"mimicry/style"
+	"time"
+)
+
+type Failure struct {
+	message error
+}
+
+func NewFailure(err error) *Failure {
+	if err == nil {
+		panic("do not create a failure with a nil error")
+	}
+	return &Failure{err}
+}
+
+func (f *Failure) Kind() string { return "failure" }
+
+func (f *Failure) Name() string {
+	return style.Problem(f.message)
+}
+
+func (f *Failure) Preview(width int) string {
+	return f.Name()
+}
+
+func (f *Failure) String(width int) string {
+	return f.Preview(width)
+}
+
+func (f *Failure) Parents(uint) ([]Tangible, Tangible) {
+	return []Tangible{}, nil
+}
+
+func (f *Failure) Children() Container {
+	return nil
+}
+
+func (f *Failure) Timestamp() time.Time {
+	return time.Time{}
+}

+ 22 - 22
pub/interfaces.go

@@ -1,22 +1,22 @@
-package pub
-
-import (
-	"time"
-)
-
-type Any any
-
-type Tangible interface {
-	Kind() string
-
-	String(width int) string
-	Preview(width int) string
-	Parents(quantity uint) ([]Tangible, Tangible)
-	Children() Container
-	Timestamp() time.Time
-}
-
-type Container interface {
-	/* result, index of next item, next collection */
-	Harvest(quantity uint, startingAt uint) ([]Tangible, Container, uint)
-}
+package pub
+
+import (
+	"time"
+)
+
+type Any any
+
+type Tangible interface {
+	Kind() string
+
+	String(width int) string
+	Preview(width int) string
+	Parents(quantity uint) ([]Tangible, Tangible)
+	Children() Container
+	Timestamp() time.Time
+}
+
+type Container interface {
+	/* result, index of next item, next collection */
+	Harvest(quantity uint, startingAt uint) ([]Tangible, Container, uint)
+}

+ 161 - 157
pub/link.go

@@ -1,157 +1,161 @@
-package pub
-
-import (
-	"net/url"
-	"errors"
-	"mimicry/object"
-	"mimicry/mime"
-	"fmt"
-	"golang.org/x/exp/slices"
-)
-
-type Link struct {
-	kind string
-	mediaType *mime.MediaType
-	mediaTypeErr error
-	uri *url.URL
-	uriErr error
-	alt string
-	altErr error
-	height uint64
-	heightErr error
-	width uint64
-	widthErr error
-}
-
-func NewLink(input any) (*Link, error) {
-	l := &Link{}
-
-	asMap, ok := input.(map[string]any)
-	if !ok {
-		return nil, fmt.Errorf("can't turn non-object %T into Link", input)
-	}
-	o := object.Object(asMap)
-
-	var err error
-	if l.kind, err = o.GetString("type"); err != nil {
-		return nil, err
-	}
-
-	if !slices.Contains([]string{
-		"Link", "Audio", "Document", "Image", "Video",
-	}, l.kind) {
-		return nil, fmt.Errorf("%w: %s is not a Link", ErrWrongType, l.kind)
-	}
-
-	if l.kind == "Link" {
-		l.uri, l.uriErr = o.GetURL("href")
-		l.height, l.heightErr = o.GetNumber("height")
-		l.width, l.widthErr = o.GetNumber("width")
-	} else {
-		l.uri, l.uriErr = o.GetURL("url")
-		l.heightErr = object.ErrKeyNotPresent
-		l.widthErr = object.ErrKeyNotPresent
-	}
-
-	l.mediaType, l.mediaTypeErr = o.GetMediaType("mediaType")
-	l.alt, l.altErr = o.GetString("name")
-
-	return l, nil
-}
-
-func (l *Link) Kind() string {
-	return l.kind
-}
-
-func (l *Link) Alt() (string, error) {
-	if l.altErr == nil {
-		return l.alt, nil
-	} else if errors.Is(l.altErr, object.ErrKeyNotPresent) {
-		if l.uriErr == nil {
-			return l.uri.String(), nil
-		} else {
-			return "", l.uriErr
-		}
-	} else {
-		return "", l.altErr
-	}
-}
-
-func (l *Link) rating() (uint64, error) {
-	var height, width uint64
-	if l.heightErr == nil {
-		height = l.height
-	} else if errors.Is(l.heightErr, object.ErrKeyNotPresent) {
-		height = 1
-	} else {
-		return 0, l.heightErr
-	}
-	if l.widthErr == nil {
-		width = l.width
-	} else if errors.Is(l.widthErr, object.ErrKeyNotPresent) {
-		width = 1
-	} else {
-		return 0, l.widthErr
-	}
-	return height * width, nil
-}
-
-func (l *Link) MediaType() (*mime.MediaType, error) {
-	return l.mediaType, l.mediaTypeErr
-}
-
-func SelectBestLink(links []*Link, supertype string) (*Link, error) {
-	if len(links) == 0 {
-		return &Link{}, errors.New("can't select best link of type " + supertype + "/* from an empty list")
-	}
-
-	bestLink := links[0]
-
-	// TODO: loop through once and validate errors, then proceed assuming no errors
-
-	for _, thisLink := range links[1:] {
-		var bestLinkSupertypeMatches bool
-		if errors.Is(bestLink.mediaTypeErr, object.ErrKeyNotPresent) {
-			bestLinkSupertypeMatches = false
-		} else if bestLink.mediaTypeErr != nil {
-			return nil, bestLink.mediaTypeErr
-		} else {
-			bestLinkSupertypeMatches = bestLink.mediaType.Supertype == supertype
-		}
-
-		var thisLinkSuperTypeMatches bool
-		if errors.Is(thisLink.mediaTypeErr, object.ErrKeyNotPresent) {
-			thisLinkSuperTypeMatches = false
-		} else if thisLink.mediaTypeErr != nil {
-			return nil, thisLink.mediaTypeErr
-		} else {
-			thisLinkSuperTypeMatches = thisLink.mediaType.Supertype == supertype
-		}
-
-		if thisLinkSuperTypeMatches && !bestLinkSupertypeMatches {
-			bestLink = thisLink
-			continue
-		} else if !thisLinkSuperTypeMatches && bestLinkSupertypeMatches {
-			continue
-		} else {
-			thisRating, err := thisLink.rating()
-			if err != nil { return nil, err }
-			bestRating, err := bestLink.rating()
-			if err != nil { return nil, err }
-			if thisRating > bestRating {
-				bestLink = thisLink
-				continue
-			}
-		}
-	}
-
-	return bestLink, nil
-}
-
-func SelectFirstLink(links []*Link) (*Link, error) {
-	if len(links) == 0 {
-		return &Link{}, errors.New("can't select first Link from an empty list of links")
-	} else {
-		return links[0], nil
-	}
-}
+package pub
+
+import (
+	"errors"
+	"fmt"
+	"golang.org/x/exp/slices"
+	"mimicry/mime"
+	"mimicry/object"
+	"net/url"
+)
+
+type Link struct {
+	kind         string
+	mediaType    *mime.MediaType
+	mediaTypeErr error
+	uri          *url.URL
+	uriErr       error
+	alt          string
+	altErr       error
+	height       uint64
+	heightErr    error
+	width        uint64
+	widthErr     error
+}
+
+func NewLink(input any) (*Link, error) {
+	l := &Link{}
+
+	asMap, ok := input.(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf("can't turn non-object %T into Link", input)
+	}
+	o := object.Object(asMap)
+
+	var err error
+	if l.kind, err = o.GetString("type"); err != nil {
+		return nil, err
+	}
+
+	if !slices.Contains([]string{
+		"Link", "Audio", "Document", "Image", "Video",
+	}, l.kind) {
+		return nil, fmt.Errorf("%w: %s is not a Link", ErrWrongType, l.kind)
+	}
+
+	if l.kind == "Link" {
+		l.uri, l.uriErr = o.GetURL("href")
+		l.height, l.heightErr = o.GetNumber("height")
+		l.width, l.widthErr = o.GetNumber("width")
+	} else {
+		l.uri, l.uriErr = o.GetURL("url")
+		l.heightErr = object.ErrKeyNotPresent
+		l.widthErr = object.ErrKeyNotPresent
+	}
+
+	l.mediaType, l.mediaTypeErr = o.GetMediaType("mediaType")
+	l.alt, l.altErr = o.GetString("name")
+
+	return l, nil
+}
+
+func (l *Link) Kind() string {
+	return l.kind
+}
+
+func (l *Link) Alt() (string, error) {
+	if l.altErr == nil {
+		return l.alt, nil
+	} else if errors.Is(l.altErr, object.ErrKeyNotPresent) {
+		if l.uriErr == nil {
+			return l.uri.String(), nil
+		} else {
+			return "", l.uriErr
+		}
+	} else {
+		return "", l.altErr
+	}
+}
+
+func (l *Link) rating() (uint64, error) {
+	var height, width uint64
+	if l.heightErr == nil {
+		height = l.height
+	} else if errors.Is(l.heightErr, object.ErrKeyNotPresent) {
+		height = 1
+	} else {
+		return 0, l.heightErr
+	}
+	if l.widthErr == nil {
+		width = l.width
+	} else if errors.Is(l.widthErr, object.ErrKeyNotPresent) {
+		width = 1
+	} else {
+		return 0, l.widthErr
+	}
+	return height * width, nil
+}
+
+func (l *Link) MediaType() (*mime.MediaType, error) {
+	return l.mediaType, l.mediaTypeErr
+}
+
+func SelectBestLink(links []*Link, supertype string) (*Link, error) {
+	if len(links) == 0 {
+		return &Link{}, errors.New("can't select best link of type " + supertype + "/* from an empty list")
+	}
+
+	bestLink := links[0]
+
+	// TODO: loop through once and validate errors, then proceed assuming no errors
+
+	for _, thisLink := range links[1:] {
+		var bestLinkSupertypeMatches bool
+		if errors.Is(bestLink.mediaTypeErr, object.ErrKeyNotPresent) {
+			bestLinkSupertypeMatches = false
+		} else if bestLink.mediaTypeErr != nil {
+			return nil, bestLink.mediaTypeErr
+		} else {
+			bestLinkSupertypeMatches = bestLink.mediaType.Supertype == supertype
+		}
+
+		var thisLinkSuperTypeMatches bool
+		if errors.Is(thisLink.mediaTypeErr, object.ErrKeyNotPresent) {
+			thisLinkSuperTypeMatches = false
+		} else if thisLink.mediaTypeErr != nil {
+			return nil, thisLink.mediaTypeErr
+		} else {
+			thisLinkSuperTypeMatches = thisLink.mediaType.Supertype == supertype
+		}
+
+		if thisLinkSuperTypeMatches && !bestLinkSupertypeMatches {
+			bestLink = thisLink
+			continue
+		} else if !thisLinkSuperTypeMatches && bestLinkSupertypeMatches {
+			continue
+		} else {
+			thisRating, err := thisLink.rating()
+			if err != nil {
+				return nil, err
+			}
+			bestRating, err := bestLink.rating()
+			if err != nil {
+				return nil, err
+			}
+			if thisRating > bestRating {
+				bestLink = thisLink
+				continue
+			}
+		}
+	}
+
+	return bestLink, nil
+}
+
+func SelectFirstLink(links []*Link) (*Link, error) {
+	if len(links) == 0 {
+		return &Link{}, errors.New("can't select first Link from an empty list of links")
+	} else {
+		return links[0], nil
+	}
+}

+ 40 - 36
pub/post.go

@@ -1,54 +1,56 @@
 package pub
 
 import (
-	"net/url"
-	"strings"
-	"time"
-	"mimicry/style"
-	"mimicry/ansi"
-	"mimicry/object"
 	"errors"
-	"mimicry/client"
 	"fmt"
 	"golang.org/x/exp/slices"
+	"mimicry/ansi"
+	"mimicry/client"
 	"mimicry/mime"
+	"mimicry/object"
 	"mimicry/render"
+	"mimicry/style"
+	"net/url"
+	"strings"
 	"sync"
+	"time"
 )
 
 type Post struct {
 	kind string
-	id *url.URL
+	id   *url.URL
 
-	title string
-	titleErr error
-	body string
-	bodyErr error
-	mediaType *mime.MediaType
+	title        string
+	titleErr     error
+	body         string
+	bodyErr      error
+	mediaType    *mime.MediaType
 	mediaTypeErr error
-	link *Link
-	linkErr error
-	created time.Time
-	createdErr error
-	edited time.Time
-	editedErr error
-	parent any
-	parentErr error
+	link         *Link
+	linkErr      error
+	created      time.Time
+	createdErr   error
+	edited       time.Time
+	editedErr    error
+	parent       any
+	parentErr    error
 
 	// just as body dies completely if members die,
 	// attachments dies completely if any member dies
-	attachments []*Link
+	attachments    []*Link
 	attachmentsErr error
 
-	creators []TangibleWithName
-	recipients []TangibleWithName
-	comments *Collection
+	creators    []TangibleWithName
+	recipients  []TangibleWithName
+	comments    *Collection
 	commentsErr error
 }
 
 func NewPost(input any, source *url.URL) (*Post, error) {
 	o, id, err := client.FetchUnknown(input, source)
-	if err != nil { return nil, err }
+	if err != nil {
+		return nil, err
+	}
 	return NewPostFromObject(o, id)
 }
 
@@ -73,7 +75,7 @@ func NewPostFromObject(o object.Object, id *url.URL) (*Post, error) {
 	p.created, p.createdErr = o.GetTime("published")
 	p.edited, p.editedErr = o.GetTime("updated")
 	p.parent, p.parentErr = o.GetAny("inReplyTo")
-	
+
 	if p.kind == "Image" || p.kind == "Audio" || p.kind == "Video" {
 		p.link, p.linkErr = getBestLinkShorthand(o, "url", strings.ToLower(p.kind))
 	} else {
@@ -82,9 +84,9 @@ func NewPostFromObject(o object.Object, id *url.URL) (*Post, error) {
 
 	var wg sync.WaitGroup
 	wg.Add(4)
-	go func() {p.creators = getActors(o, "attributedTo", p.id); wg.Done()}()
-	go func() {p.recipients = getActors(o, "audience", p.id); wg.Done()}()
-	go func() {p.attachments, p.attachmentsErr = getLinks(o, "attachment"); wg.Done()}()
+	go func() { p.creators = getActors(o, "attributedTo", p.id); wg.Done() }()
+	go func() { p.recipients = getActors(o, "audience", p.id); wg.Done() }()
+	go func() { p.attachments, p.attachmentsErr = getLinks(o, "attachment"); wg.Done() }()
 	go func() {
 		p.comments, p.commentsErr = getCollection(o, "replies", p.id)
 		if errors.Is(p.commentsErr, object.ErrKeyNotPresent) {
@@ -96,7 +98,7 @@ func NewPostFromObject(o object.Object, id *url.URL) (*Post, error) {
 	return p, nil
 }
 
-func (p *Post) Kind() (string) {
+func (p *Post) Kind() string {
 	return p.kind
 }
 
@@ -151,7 +153,7 @@ func (p *Post) header(width int) string {
 		output += " by "
 		for i, creator := range p.creators {
 			output += style.Color(creator.Name())
-			if i != len(p.creators) - 1 {
+			if i != len(p.creators)-1 {
 				output += ", "
 			}
 		}
@@ -160,7 +162,7 @@ func (p *Post) header(width int) string {
 		output += " to "
 		for i, recipient := range p.recipients {
 			output += style.Color(recipient.Name())
-			if i != len(p.recipients) - 1 {
+			if i != len(p.recipients)-1 {
 				output += ", "
 			}
 		}
@@ -210,7 +212,9 @@ func (p *Post) supplement(width int) (string, bool) {
 
 	output := ""
 	for _, attachment := range p.attachments {
-		if output != "" { output += "\n" }
+		if output != "" {
+			output += "\n"
+		}
 		alt, err := attachment.Alt()
 		if err != nil {
 			output += style.Problem(err)
@@ -247,7 +251,7 @@ func (p Post) String(width int) string {
 	if attachments, present := p.supplement(width - 4); present {
 		output += "\n\n" + ansi.Indent(attachments, "  ", true)
 	}
-	
+
 	output += "\n\n" + p.footer(width)
 
 	return output
@@ -258,7 +262,7 @@ func (p *Post) Preview(width int) string {
 
 	if body, present := p.center(width); present {
 		if attachments, present := p.supplement(width); present {
-			output += "\n" + ansi.Snip(body + "\n" + attachments, width, 4, style.Color("\u2026"))
+			output += "\n" + ansi.Snip(body+"\n"+attachments, width, 4, style.Color("\u2026"))
 		} else {
 			output += "\n" + ansi.Snip(body, width, 4, style.Color("\u2026"))
 		}

+ 28 - 28
pub/user-input.go

@@ -1,28 +1,28 @@
-package pub
-
-import (
-	"strings"
-	"mimicry/client"
-)
-
-func FetchUserInput(text string) Any {
-	if strings.HasPrefix(text, "@") {
-		link, err := client.ResolveWebfinger(text)
-		if err != nil {
-			return NewFailure(err)
-		}
-		return NewTangible(link, nil)
-	}
-
-	if strings.HasPrefix(text, "/") ||
-		strings.HasPrefix(text, "./") ||
-		strings.HasPrefix(text, "../") {
-		object, err := client.FetchFromFile(text)
-		if err != nil {
-			return NewFailure(err)
-		}
-		return NewTangible(object, nil)
-	}
-
-	return NewTangible(text, nil)
-}
+package pub
+
+import (
+	"mimicry/client"
+	"strings"
+)
+
+func FetchUserInput(text string) Any {
+	if strings.HasPrefix(text, "@") {
+		link, err := client.ResolveWebfinger(text)
+		if err != nil {
+			return NewFailure(err)
+		}
+		return NewTangible(link, nil)
+	}
+
+	if strings.HasPrefix(text, "/") ||
+		strings.HasPrefix(text, "./") ||
+		strings.HasPrefix(text, "../") {
+		object, err := client.FetchFromFile(text)
+		if err != nil {
+			return NewFailure(err)
+		}
+		return NewTangible(object, nil)
+	}
+
+	return NewTangible(text, nil)
+}

+ 41 - 39
render/render.go

@@ -1,39 +1,41 @@
-package render
-
-import (
-	"errors"
-	"mimicry/hypertext"
-	"mimicry/plaintext"
-	"mimicry/gemtext"
-	"mimicry/markdown"
-	"strings"
-	"unicode"
-)
-
-// TODO: perhaps `dropControlCharacters` should happen to all
-//	`getNatural` strings when they are pulled from the JSON
-// TODO: need to add a width parameter to all of this
-func Render(text string, mediaType string, width int) (string, error) {
-	text = strings.Map(dropControlCharacters, text)
-
-	switch {
-	case mediaType == "text/plain": 
-		return plaintext.Render(text, width)
-	case mediaType == "text/html":
-		return hypertext.Render(text, width)
-	case mediaType == "text/gemini":
-		return gemtext.Render(text, width)
-	case mediaType == "text/markdown":
-		return markdown.Render(text, width)
-	default:
-		return "", errors.New("cannot render text of mime type " + mediaType)
-	}
-}
-
-func dropControlCharacters(character rune) rune {
-	if unicode.IsControl(character) && character != '\t' && character != '\n' {
-		return -1 // drop the character
-	}
-
-	return character
-}
+package render
+
+import (
+	"errors"
+	"mimicry/gemtext"
+	"mimicry/hypertext"
+	"mimicry/markdown"
+	"mimicry/plaintext"
+	"strings"
+	"unicode"
+)
+
+// TODO: perhaps `dropControlCharacters` should happen to all
+//
+//	`getNatural` strings when they are pulled from the JSON
+//
+// TODO: need to add a width parameter to all of this
+func Render(text string, mediaType string, width int) (string, error) {
+	text = strings.Map(dropControlCharacters, text)
+
+	switch {
+	case mediaType == "text/plain":
+		return plaintext.Render(text, width)
+	case mediaType == "text/html":
+		return hypertext.Render(text, width)
+	case mediaType == "text/gemini":
+		return gemtext.Render(text, width)
+	case mediaType == "text/markdown":
+		return markdown.Render(text, width)
+	default:
+		return "", errors.New("cannot render text of mime type " + mediaType)
+	}
+}
+
+func dropControlCharacters(character rune) rune {
+	if unicode.IsControl(character) && character != '\t' && character != '\n' {
+		return -1 // drop the character
+	}
+
+	return character
+}

+ 20 - 20
render/render_test.go

@@ -1,20 +1,20 @@
-package render
-
-import (
-	"testing"
-	"mimicry/style"
-	"mimicry/util"
-)
-
-func TestControlCharacterEscapes(t *testing.T) {
-	input := "Yes, \u0000Jim, I\nfound it\tunder \u001Bhttp://www.w3.org/Addressing/"
-	output, err := Render(input, "text/plain", 50)
-	if err != nil {
-		panic(err)
-	}
-
-	expected := "Yes, Jim, I\nfound it\tunder " +
-		style.Link("http://www.w3.org/Addressing/")
-
-	util.AssertEqual(expected, output, t)
-}
+package render
+
+import (
+	"mimicry/style"
+	"mimicry/util"
+	"testing"
+)
+
+func TestControlCharacterEscapes(t *testing.T) {
+	input := "Yes, \u0000Jim, I\nfound it\tunder \u001Bhttp://www.w3.org/Addressing/"
+	output, err := Render(input, "text/plain", 50)
+	if err != nil {
+		panic(err)
+	}
+
+	expected := "Yes, Jim, I\nfound it\tunder " +
+		style.Link("http://www.w3.org/Addressing/")
+
+	util.AssertEqual(expected, output, t)
+}

+ 111 - 111
splicer/splicer.go

@@ -1,111 +1,111 @@
-package splicer
-
-import (
-	"mimicry/pub"
-	"sync"
-)
-
-type Splicer []struct {
-	basepoint uint
-	page      pub.Container
-	element   pub.Tangible
-}
-
-func (s Splicer) Harvest(quantity uint, startingPoint uint) ([]pub.Tangible, pub.Container, uint) {
-	clone := s.clone()
-
-	for i := 0; i < int(startingPoint); i++ {
-		clone.microharvest()
-	}
-
-	output := make([]pub.Tangible, 0, quantity)
-	for i := 0; i < int(quantity); i++ {
-		harvested := clone.microharvest()
-		if harvested == nil {
-			break
-		}
-		output = append(output, harvested)
-	}
-
-	return output, clone, 0
-}
-
-func (s Splicer) clone() *Splicer {
-	newSplicer := make(Splicer, len(s))
-	copy(newSplicer, s)
-	return &newSplicer
-}
-
-func (s Splicer) microharvest() pub.Tangible {
-	var mostRecent pub.Tangible
-	var mostRecentIndex int
-	for i, candidate := range s {
-		if mostRecent == nil {
-			mostRecent = candidate.element
-			mostRecentIndex = i
-			continue
-		}
-
-		if candidate.element == nil {
-			continue
-		}
-
-		if candidate.element.Timestamp().After(mostRecent.Timestamp()) {
-			mostRecent = candidate.element
-			mostRecentIndex = i
-			continue
-		}
-	}
-
-	if mostRecent == nil {
-		return nil
-	}
-
-	if s[mostRecentIndex].page != nil {
-		var elements []pub.Tangible
-		elements, s[mostRecentIndex].page, s[mostRecentIndex].basepoint = s[mostRecentIndex].page.Harvest(1, s[mostRecentIndex].basepoint)
-		if len(elements) > 1 {
-			panic("harvest returned more that one element when I only asked for one")
-		} else {
-			s[mostRecentIndex].element = elements[0]
-		}
-	} else {
-		s[mostRecentIndex].element = nil
-	}
-
-	return mostRecent
-}
-
-func NewSplicer(inputs []string) *Splicer {
-	s := make(Splicer, len(inputs))
-	var wg sync.WaitGroup
-	for i, input := range inputs {
-		i := i
-		input := input
-		wg.Add(1)
-		go func() {
-			fetched := pub.FetchUserInput(input)
-			var children pub.Container
-			switch narrowed := fetched.(type) {
-			case pub.Tangible:
-				children = narrowed.Children()
-			case *pub.Collection:
-				children = narrowed
-			default:
-				panic("cannot splice non-Tangible, non-Collection")
-			}
-
-			var elements []pub.Tangible
-			elements, s[i].page, s[i].basepoint = children.Harvest(1, 0)
-			if len(elements) > 1 {
-				panic("harvest returned more that one element when I only asked for one")
-			} else {
-				s[i].element = elements[0]
-			}
-			wg.Done()
-		}()
-	}
-	wg.Wait()
-
-	return &s
-}
+package splicer
+
+import (
+	"mimicry/pub"
+	"sync"
+)
+
+type Splicer []struct {
+	basepoint uint
+	page      pub.Container
+	element   pub.Tangible
+}
+
+func (s Splicer) Harvest(quantity uint, startingPoint uint) ([]pub.Tangible, pub.Container, uint) {
+	clone := s.clone()
+
+	for i := 0; i < int(startingPoint); i++ {
+		clone.microharvest()
+	}
+
+	output := make([]pub.Tangible, 0, quantity)
+	for i := 0; i < int(quantity); i++ {
+		harvested := clone.microharvest()
+		if harvested == nil {
+			break
+		}
+		output = append(output, harvested)
+	}
+
+	return output, clone, 0
+}
+
+func (s Splicer) clone() *Splicer {
+	newSplicer := make(Splicer, len(s))
+	copy(newSplicer, s)
+	return &newSplicer
+}
+
+func (s Splicer) microharvest() pub.Tangible {
+	var mostRecent pub.Tangible
+	var mostRecentIndex int
+	for i, candidate := range s {
+		if mostRecent == nil {
+			mostRecent = candidate.element
+			mostRecentIndex = i
+			continue
+		}
+
+		if candidate.element == nil {
+			continue
+		}
+
+		if candidate.element.Timestamp().After(mostRecent.Timestamp()) {
+			mostRecent = candidate.element
+			mostRecentIndex = i
+			continue
+		}
+	}
+
+	if mostRecent == nil {
+		return nil
+	}
+
+	if s[mostRecentIndex].page != nil {
+		var elements []pub.Tangible
+		elements, s[mostRecentIndex].page, s[mostRecentIndex].basepoint = s[mostRecentIndex].page.Harvest(1, s[mostRecentIndex].basepoint)
+		if len(elements) > 1 {
+			panic("harvest returned more that one element when I only asked for one")
+		} else {
+			s[mostRecentIndex].element = elements[0]
+		}
+	} else {
+		s[mostRecentIndex].element = nil
+	}
+
+	return mostRecent
+}
+
+func NewSplicer(inputs []string) *Splicer {
+	s := make(Splicer, len(inputs))
+	var wg sync.WaitGroup
+	for i, input := range inputs {
+		i := i
+		input := input
+		wg.Add(1)
+		go func() {
+			fetched := pub.FetchUserInput(input)
+			var children pub.Container
+			switch narrowed := fetched.(type) {
+			case pub.Tangible:
+				children = narrowed.Children()
+			case *pub.Collection:
+				children = narrowed
+			default:
+				panic("cannot splice non-Tangible, non-Collection")
+			}
+
+			var elements []pub.Tangible
+			elements, s[i].page, s[i].basepoint = children.Harvest(1, 0)
+			if len(elements) > 1 {
+				panic("harvest returned more that one element when I only asked for one")
+			} else {
+				s[i].element = elements[0]
+			}
+			wg.Done()
+		}()
+	}
+	wg.Wait()
+
+	return &s
+}

+ 76 - 76
style/style.go

@@ -1,76 +1,76 @@
-package style
-
-import (
-	"fmt"
-	"strings"
-	"mimicry/ansi"
-)
-
-func background(text string, r uint8, g uint8, b uint8) string {
-	prefix := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
-	return ansi.Apply(text, prefix)
-}
-
-func foreground(text string, r uint8, g uint8, b uint8) string {
-	prefix := fmt.Sprintf("38;2;%d;%d;%d", r, g, b)
-	return ansi.Apply(text, prefix)
-}
-
-func Bold(text string) string {
-	return ansi.Apply(text, "1")
-}
-
-func Strikethrough(text string) string {
-	return ansi.Apply(text, "9")
-}
-
-func Underline(text string) string {
-	return ansi.Apply(text, "4")
-}
-
-func Italic(text string) string {
-	return ansi.Apply(text, "3")
-}
-
-func Code(text string) string {
-	return background(text, 75, 75, 75)
-}
-
-func Highlight(text string) string {
-	return background(text, 13, 125, 0)
-}
-
-func Color(text string) string {
-	return foreground(text, 164, 245, 155)
-}
-
-func Problem(text error) string {
-	return foreground(text.Error(), 156, 53, 53)
-}
-
-func Link(text string) string {
-	return Underline(Color(text))
-}
-
-func CodeBlock(text string) string {
-	return Code(text)
-}
-
-func QuoteBlock(text string) string {
-	prefixed := ansi.Indent(text, "▌", true)
-	return Color(prefixed)
-}
-
-func LinkBlock(text string) string {
-	return "‣ " + ansi.Indent(Link(text), "  ", false)
-}
-
-func Header(text string, level uint) string {
-	indented := ansi.Indent(text, strings.Repeat(" ", int(level+1)), false)
-	withPrefix := strings.Repeat("⯁", int(level)) + " " + indented
-	return Color(Bold(withPrefix))
-}
-
-func Bullet(text string) string {
-	return "• " + ansi.Indent(text, "  ", false)
-}
+package style
+
+import (
+	"fmt"
+	"mimicry/ansi"
+	"strings"
+)
+
+func background(text string, r uint8, g uint8, b uint8) string {
+	prefix := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
+	return ansi.Apply(text, prefix)
+}
+
+func foreground(text string, r uint8, g uint8, b uint8) string {
+	prefix := fmt.Sprintf("38;2;%d;%d;%d", r, g, b)
+	return ansi.Apply(text, prefix)
+}
+
+func Bold(text string) string {
+	return ansi.Apply(text, "1")
+}
+
+func Strikethrough(text string) string {
+	return ansi.Apply(text, "9")
+}
+
+func Underline(text string) string {
+	return ansi.Apply(text, "4")
+}
+
+func Italic(text string) string {
+	return ansi.Apply(text, "3")
+}
+
+func Code(text string) string {
+	return background(text, 75, 75, 75)
+}
+
+func Highlight(text string) string {
+	return background(text, 13, 125, 0)
+}
+
+func Color(text string) string {
+	return foreground(text, 164, 245, 155)
+}
+
+func Problem(text error) string {
+	return foreground(text.Error(), 156, 53, 53)
+}
+
+func Link(text string) string {
+	return Underline(Color(text))
+}
+
+func CodeBlock(text string) string {
+	return Code(text)
+}
+
+func QuoteBlock(text string) string {
+	prefixed := ansi.Indent(text, "▌", true)
+	return Color(prefixed)
+}
+
+func LinkBlock(text string) string {
+	return "‣ " + ansi.Indent(Link(text), "  ", false)
+}
+
+func Header(text string, level uint) string {
+	indented := ansi.Indent(text, strings.Repeat(" ", int(level+1)), false)
+	withPrefix := strings.Repeat("⯁", int(level)) + " " + indented
+	return Color(Bold(withPrefix))
+}
+
+func Bullet(text string) string {
+	return "• " + ansi.Indent(text, "  ", false)
+}

+ 201 - 193
ui/ui.go

@@ -1,193 +1,201 @@
-package ui
-
-import (
-	"mimicry/pub"
-	"mimicry/ansi"
-	"mimicry/feed"
-	"fmt"
-	"sync"
-	"mimicry/style"
-	"mimicry/config"
-	"mimicry/splicer"
-)
-
-type State struct {
-	// TODO: the part stored in the history array is
-	// called page, page will be renamed to children
-	m *sync.Mutex
-
-	feed *feed.Feed
-	index int
-	
-	frontier pub.Tangible
-	loadingUp bool
-	
-	page pub.Container
-	basepoint uint
-	loadingDown bool
-	
-	width int
-	height int
-	output func(string)
-	
-	config *config.Config
-}
-
-func (s *State) View() string {
-	var top, center, bottom string
-	for i := s.index - s.config.Context; i <= s.index + s.config.Context; i++ {
-		if !s.feed.Contains(i) {
-			continue
-		}
-		var serialized string
-		if i == 0 {
-			serialized = s.feed.Get(i).String(s.width - 4)
-		} else if i > 0 {
-			serialized = "╰ " + ansi.Indent(s.feed.Get(i).Preview(s.width - 4), "  ", false)
-		} else {
-			serialized = s.feed.Get(i).Preview(s.width - 4)
-		}
-		if i == s.index {
-			center = ansi.Indent(serialized, "┃ ", true)
-		} else if i < s.index {
-			if top != "" { top += "\n" }
-			top += ansi.Indent(serialized + "\n│", "  ", true)
-		} else {
-			if bottom != "" { bottom += "\n" }
-			bottom += ansi.Indent("│\n" + serialized, "  ", true)
-		}
-	}
-	if s.loadingUp {
-		if top != "" { top += "\n" }
-		top = "  " + style.Color("Loading…") + "\n" + top
-	}
-	if s.loadingDown {
-		if bottom != "" { bottom += "\n" }
-		bottom += "\n  " + style.Color("Loading…")
-	}
-	return ansi.CenterVertically(top, center, bottom, uint(s.height))
-}
-
-func (s *State) Update(input byte) {
-	switch input {
-	case 'k': // up
-		s.m.Lock()
-		if s.feed.Contains(s.index - 1) {
-			s.index -= 1
-		}
-		s.loadSurroundings()
-		s.output(s.View())
-		s.m.Unlock()
-	case 'j': // down
-		s.m.Lock()
-		if s.feed.Contains(s.index + 1) {
-			s.index += 1
-		}
-		s.loadSurroundings()
-		s.output(s.View())
-		s.m.Unlock()
-	case 'g': // return to OP
-		s.m.Lock()
-		s.index = 0
-		s.output(s.View())
-		s.m.Unlock()
-	case ' ': // select
-		s.m.Lock()
-		s.switchTo(s.feed.Get(s.index))
-		s.m.Unlock()
-	}
-	// TODO: the catchall down here will be to look at s.feed.Get(s.index).References()
-	// for urls to switch to
-}
-
-func (s *State) switchTo(item pub.Any)  {
-	switch narrowed := item.(type) {
-	case pub.Tangible:
-		s.feed = feed.Create(narrowed)
-		s.frontier = narrowed
-		s.page = narrowed.Children()
-		s.index = 0
-		s.loadingUp = false
-		s.loadingDown = false
-		s.basepoint = 0
-		s.loadSurroundings()
-	case pub.Container:
-		var children []pub.Tangible
-		children, s.page, s.basepoint = narrowed.Harvest(uint(s.config.Context), 0)
-		s.feed = feed.CreateAndAppend(children)
-		s.index = 1
-		s.loadingUp = false
-		s.loadingDown = false
-		s.basepoint = 0
-	default:
-		panic(fmt.Sprintf("unrecognized non-Tangible non-Container: %T", item))
-	}
-	s.output(s.View())
-}
-
-func (s *State) SetWidthHeight(width int, height int) {
-	s.m.Lock()
-	defer s.m.Unlock()
-	if s.width == width && s.height == height {
-		return
-	}
-	s.width = width
-	s.height = height
-	s.output(s.View())
-}
-
-func (s *State) loadSurroundings() {
-	var prior State = *s
-	if !s.loadingUp && !s.feed.Contains(s.index - s.config.Context) && s.frontier != nil {
-		s.loadingUp = true
-		go func() {
-			parents, newFrontier := prior.frontier.Parents(uint(prior.config.Context))
-			prior.feed.Prepend(parents)
-			s.m.Lock()
-			if prior.feed == s.feed {
-				s.frontier = newFrontier
-				s.loadingUp = false
-				s.output(s.View())
-			}
-			s.m.Unlock()
-		}()
-	}
-	if !s.loadingDown && !s.feed.Contains(s.index + s.config.Context) && s.page != nil {
-		s.loadingDown = true
-		go func() {
-			children, newPage, newBasepoint := prior.page.Harvest(uint(prior.config.Context), prior.basepoint)
-			prior.feed.Append(children)
-			s.m.Lock()
-			if prior.feed == s.feed {
-				s.page = newPage
-				s.basepoint = newBasepoint
-				s.loadingDown = false
-				s.output(s.View())
-			}
-			s.m.Unlock()
-		}()
-	}
-}
-
-func (s *State) Open(input string) {
-	s.output(ansi.CenterVertically("", style.Color("  Opening…"), "", uint(s.height)))
-	s.switchTo(pub.FetchUserInput(input))
-}
-
-func (s *State) Feed(input string) {
-	s.output(ansi.CenterVertically("", style.Color("  Loading feed…"), "", uint(s.height)))
-	s.switchTo(splicer.NewSplicer(s.config.Feeds[input]))
-}
-
-func NewState(config *config.Config, width int, height int, output func(string)) *State {
-	s := &State{
-		feed: &feed.Feed{},
-		index: 0,
-		config: config,
-		width: width,
-		height: height,
-		output: output,
-		m: &sync.Mutex{},
-	}
-	return s
-}
+package ui
+
+import (
+	"fmt"
+	"mimicry/ansi"
+	"mimicry/config"
+	"mimicry/feed"
+	"mimicry/pub"
+	"mimicry/splicer"
+	"mimicry/style"
+	"sync"
+)
+
+type State struct {
+	// TODO: the part stored in the history array is
+	// called page, page will be renamed to children
+	m *sync.Mutex
+
+	feed  *feed.Feed
+	index int
+
+	frontier  pub.Tangible
+	loadingUp bool
+
+	page        pub.Container
+	basepoint   uint
+	loadingDown bool
+
+	width  int
+	height int
+	output func(string)
+
+	config *config.Config
+}
+
+func (s *State) View() string {
+	var top, center, bottom string
+	for i := s.index - s.config.Context; i <= s.index+s.config.Context; i++ {
+		if !s.feed.Contains(i) {
+			continue
+		}
+		var serialized string
+		if i == 0 {
+			serialized = s.feed.Get(i).String(s.width - 4)
+		} else if i > 0 {
+			serialized = "╰ " + ansi.Indent(s.feed.Get(i).Preview(s.width-4), "  ", false)
+		} else {
+			serialized = s.feed.Get(i).Preview(s.width - 4)
+		}
+		if i == s.index {
+			center = ansi.Indent(serialized, "┃ ", true)
+		} else if i < s.index {
+			if top != "" {
+				top += "\n"
+			}
+			top += ansi.Indent(serialized+"\n│", "  ", true)
+		} else {
+			if bottom != "" {
+				bottom += "\n"
+			}
+			bottom += ansi.Indent("│\n"+serialized, "  ", true)
+		}
+	}
+	if s.loadingUp {
+		if top != "" {
+			top += "\n"
+		}
+		top = "  " + style.Color("Loading…") + "\n" + top
+	}
+	if s.loadingDown {
+		if bottom != "" {
+			bottom += "\n"
+		}
+		bottom += "\n  " + style.Color("Loading…")
+	}
+	return ansi.CenterVertically(top, center, bottom, uint(s.height))
+}
+
+func (s *State) Update(input byte) {
+	switch input {
+	case 'k': // up
+		s.m.Lock()
+		if s.feed.Contains(s.index - 1) {
+			s.index -= 1
+		}
+		s.loadSurroundings()
+		s.output(s.View())
+		s.m.Unlock()
+	case 'j': // down
+		s.m.Lock()
+		if s.feed.Contains(s.index + 1) {
+			s.index += 1
+		}
+		s.loadSurroundings()
+		s.output(s.View())
+		s.m.Unlock()
+	case 'g': // return to OP
+		s.m.Lock()
+		s.index = 0
+		s.output(s.View())
+		s.m.Unlock()
+	case ' ': // select
+		s.m.Lock()
+		s.switchTo(s.feed.Get(s.index))
+		s.m.Unlock()
+	}
+	// TODO: the catchall down here will be to look at s.feed.Get(s.index).References()
+	// for urls to switch to
+}
+
+func (s *State) switchTo(item pub.Any) {
+	switch narrowed := item.(type) {
+	case pub.Tangible:
+		s.feed = feed.Create(narrowed)
+		s.frontier = narrowed
+		s.page = narrowed.Children()
+		s.index = 0
+		s.loadingUp = false
+		s.loadingDown = false
+		s.basepoint = 0
+		s.loadSurroundings()
+	case pub.Container:
+		var children []pub.Tangible
+		children, s.page, s.basepoint = narrowed.Harvest(uint(s.config.Context), 0)
+		s.feed = feed.CreateAndAppend(children)
+		s.index = 1
+		s.loadingUp = false
+		s.loadingDown = false
+		s.basepoint = 0
+	default:
+		panic(fmt.Sprintf("unrecognized non-Tangible non-Container: %T", item))
+	}
+	s.output(s.View())
+}
+
+func (s *State) SetWidthHeight(width int, height int) {
+	s.m.Lock()
+	defer s.m.Unlock()
+	if s.width == width && s.height == height {
+		return
+	}
+	s.width = width
+	s.height = height
+	s.output(s.View())
+}
+
+func (s *State) loadSurroundings() {
+	var prior State = *s
+	if !s.loadingUp && !s.feed.Contains(s.index-s.config.Context) && s.frontier != nil {
+		s.loadingUp = true
+		go func() {
+			parents, newFrontier := prior.frontier.Parents(uint(prior.config.Context))
+			prior.feed.Prepend(parents)
+			s.m.Lock()
+			if prior.feed == s.feed {
+				s.frontier = newFrontier
+				s.loadingUp = false
+				s.output(s.View())
+			}
+			s.m.Unlock()
+		}()
+	}
+	if !s.loadingDown && !s.feed.Contains(s.index+s.config.Context) && s.page != nil {
+		s.loadingDown = true
+		go func() {
+			children, newPage, newBasepoint := prior.page.Harvest(uint(prior.config.Context), prior.basepoint)
+			prior.feed.Append(children)
+			s.m.Lock()
+			if prior.feed == s.feed {
+				s.page = newPage
+				s.basepoint = newBasepoint
+				s.loadingDown = false
+				s.output(s.View())
+			}
+			s.m.Unlock()
+		}()
+	}
+}
+
+func (s *State) Open(input string) {
+	s.output(ansi.CenterVertically("", style.Color("  Opening…"), "", uint(s.height)))
+	s.switchTo(pub.FetchUserInput(input))
+}
+
+func (s *State) Feed(input string) {
+	s.output(ansi.CenterVertically("", style.Color("  Loading feed…"), "", uint(s.height)))
+	s.switchTo(splicer.NewSplicer(s.config.Feeds[input]))
+}
+
+func NewState(config *config.Config, width int, height int, output func(string)) *State {
+	s := &State{
+		feed:   &feed.Feed{},
+		index:  0,
+		config: config,
+		width:  width,
+		height: height,
+		output: output,
+		m:      &sync.Mutex{},
+	}
+	return s
+}

+ 11 - 11
util/util.go

@@ -1,11 +1,11 @@
-package util
-
-import (
-	"testing"
-)
-
-func AssertEqual(expected string, output string, t *testing.T) {
-	if expected != output {
-		t.Fatalf("Expected `%s` not `%s`\n", expected, output)
-	}
-}
+package util
+
+import (
+	"testing"
+)
+
+func AssertEqual(expected string, output string, t *testing.T) {
+	if expected != output {
+		t.Fatalf("Expected `%s` not `%s`\n", expected, output)
+	}
+}