diff --git a/_datafiles/world/default/ansi-aliases.yaml b/_datafiles/world/default/ansi-aliases.yaml
index f60c17fc..e58b518e 100644
--- a/_datafiles/world/default/ansi-aliases.yaml
+++ b/_datafiles/world/default/ansi-aliases.yaml
@@ -153,4 +153,13 @@ colors:
mob-corpse: 67
user-corpse: 143
tip-text: 219
- character-joined: 120
\ No newline at end of file
+ character-joined: 120
+ md-h1-prefix: black-bold
+ md-h1: magenta
+ md-h2: yellow-bold
+ md-li: white-bold
+ md-sp1: command
+ md-bold: 9
+ md-em: 208
+ md-hr1: 4
+ md-hr2: 4
\ No newline at end of file
diff --git a/_datafiles/world/default/templates/help/attack.md b/_datafiles/world/default/templates/help/attack.md
new file mode 100644
index 00000000..7d422586
--- /dev/null
+++ b/_datafiles/world/default/templates/help/attack.md
@@ -0,0 +1,20 @@
+# Help for ~attack~
+
+The ~attack~ command engages in combat with a player or NPC.
+Once started, combat continues until someone flees or someone dies.
+
+## Usage:
+
+ ~attack goblin~
+ This would start combat with the goblin.
+
+*Chance to Hit* is calculated as follows:
+
+- **attackersSpeed** / (**atackerSpeed** + **defenderSpeed**) * **70** + **30**
+
+You always have a minimum 5% chance to miss, and a minimum 5% chance to hit.
+
+*Crit Chance* is calculated as follows:
+
+- (**Strength** + **Speed**) / (**attackerLevel** - **defenderLevel**) + **5**
+
diff --git a/_datafiles/world/default/templates/help/attack.template b/_datafiles/world/default/templates/help/attack.template
index 3ba59e2e..a96a7e8b 100644
--- a/_datafiles/world/default/templates/help/attack.template
+++ b/_datafiles/world/default/templates/help/attack.template
@@ -5,10 +5,11 @@ until someone flees or someone dies.
Usage:
- attack goblin
+ attack goblin
This would start combat with the goblin
When in combat your chance to hit is: attackersSpeed / (atackerSpeed+defenderSpeed) * 70 + 30
You always have a minimum 5% chance to miss and a minimum 5% chance to hit
Crits Chance is calculated as follows: (Strength+Speed) / (attackerLevel-defenderLevel) + 5
+
diff --git a/_datafiles/world/empty/ansi-aliases.yaml b/_datafiles/world/empty/ansi-aliases.yaml
index f60c17fc..e58b518e 100644
--- a/_datafiles/world/empty/ansi-aliases.yaml
+++ b/_datafiles/world/empty/ansi-aliases.yaml
@@ -153,4 +153,13 @@ colors:
mob-corpse: 67
user-corpse: 143
tip-text: 219
- character-joined: 120
\ No newline at end of file
+ character-joined: 120
+ md-h1-prefix: black-bold
+ md-h1: magenta
+ md-h2: yellow-bold
+ md-li: white-bold
+ md-sp1: command
+ md-bold: 9
+ md-em: 208
+ md-hr1: 4
+ md-hr2: 4
\ No newline at end of file
diff --git a/_datafiles/world/empty/templates/help/attack.md b/_datafiles/world/empty/templates/help/attack.md
new file mode 100644
index 00000000..7d422586
--- /dev/null
+++ b/_datafiles/world/empty/templates/help/attack.md
@@ -0,0 +1,20 @@
+# Help for ~attack~
+
+The ~attack~ command engages in combat with a player or NPC.
+Once started, combat continues until someone flees or someone dies.
+
+## Usage:
+
+ ~attack goblin~
+ This would start combat with the goblin.
+
+*Chance to Hit* is calculated as follows:
+
+- **attackersSpeed** / (**atackerSpeed** + **defenderSpeed**) * **70** + **30**
+
+You always have a minimum 5% chance to miss, and a minimum 5% chance to hit.
+
+*Crit Chance* is calculated as follows:
+
+- (**Strength** + **Speed**) / (**attackerLevel** - **defenderLevel**) + **5**
+
diff --git a/internal/markdown/README.md b/internal/markdown/README.md
new file mode 100644
index 00000000..47b15db1
--- /dev/null
+++ b/internal/markdown/README.md
@@ -0,0 +1,143 @@
+## Supported Markdown
+
+### Rules
+
+* Two line breaks starts a new paragraph of text.
+* Single line breaks collapse into a single line, UNLESS the previous line ended with a double space (Not my convention!)
+* Most wrapping markdown can be nested, bold within emphasis inside of a Heading, etc.
+
+### Headings
+
+Markdown:
+
+`# Heading`
+
+Html:
+
+```
+Html tag output:
Heading
+```
+
+Ansitags:
+
+```
+.: This is a HEADING
+```
+
+Note: Adding additional #'s will increment the ``
+Note: Ansitags only add the prefix for h1
+
+### Horizontal Lines
+
+Markdown:
+
+```
+---
+===
+:::
+```
+
+Html:
+
+```
+
+```
+
+Ansitags:
+
+```
+--------------------------------------------------------------------------------
+================================================================================
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+```
+
+### Lists
+
+**Markdown:**
+
+```
+- List Item 1
+ - List Sub 1
+- List Item 2
+- List Item 3
+```
+
+**Html:**
+
+```
+
+ - List Item 1
+
+
+ - List Item 2
+ - List Item 3
+
+```
+
+**Ansitags:**
+
+```
+- List Item 1
+ - List Sub 1
+- List Item 2
+- List Item 3
+```
+
+### Emphasis
+
+**Markdown:**
+
+`*Emphasize me*`
+
+**Html:**
+
+```
+Emphasize me
+```
+
+**Ansitags:**
+
+```
+Emphasize me
+```
+
+### Bold
+
+**Markdown:**
+
+`**Bold me**`
+
+**Html:**
+
+```
+Bold me
+```
+
+**Ansitags:**
+
+```
+Bold me
+```
+
+### Special
+
+**Markdown:**
+
+`~I'm Special~`
+
+**Html:**
+
+```
+I'm Special
+```
+
+**Ansitags:**
+
+```
+I'm Special
+```
+
+**Notes:** Additional wrapping ~'s increment the number: `~~I'm Special~~`, `~~~I'm Special~~~` and so on.
+**Notes:** `~~` is typically treated as a strikethrough in markdown.
diff --git a/internal/markdown/ast.go b/internal/markdown/ast.go
new file mode 100644
index 00000000..7c0f7394
--- /dev/null
+++ b/internal/markdown/ast.go
@@ -0,0 +1,86 @@
+package markdown
+
+import (
+ "fmt"
+)
+
+// NodeType identifies the kind of AST node.
+type NodeType string
+
+const (
+ DocumentNode NodeType = "Document"
+ HeadingNode NodeType = "Heading"
+ ParagraphNode NodeType = "Paragraph"
+ HorizontalLineNode NodeType = "HorizontalLine"
+ HardBreakNode NodeType = "HardBreak"
+ ListNode NodeType = "List"
+ ListItemNode NodeType = "ListItem"
+ TextNode NodeType = "Text"
+ StrongNode NodeType = "Strong"
+ EmphasisNode NodeType = "Emphasis"
+ SpecialNode NodeType = "Special"
+)
+
+var (
+ activeFormatter Formatter = ReMarkdown{}
+)
+
+func SetFormatter(newFormatter Formatter) {
+ activeFormatter = newFormatter
+}
+
+// Node is an element in the AST.
+type Node interface {
+ Type() NodeType
+ Children() []Node
+ String(int) string
+}
+
+// baseNode provides common fields.
+type baseNode struct {
+ nodeType NodeType
+ nodeChildren []Node
+ level int
+ content string
+}
+
+func (n *baseNode) Type() NodeType { return n.nodeType }
+func (n *baseNode) Children() []Node { return n.nodeChildren }
+func (n *baseNode) String(depth int) string {
+ ret := ``
+ for _, c := range n.Children() {
+ if n.Type() == ListNode {
+ ret += c.String(depth - 1)
+ } else {
+ ret += c.String(depth + 1)
+ }
+
+ }
+
+ switch n.Type() {
+ case DocumentNode:
+ return activeFormatter.Document(ret, depth)
+ case HeadingNode:
+ return activeFormatter.Heading(ret, n.level)
+ case ParagraphNode:
+ return activeFormatter.Paragraph(ret, depth)
+ case HorizontalLineNode:
+ return activeFormatter.HorizontalLine(n.content, depth)
+ case HardBreakNode:
+ return activeFormatter.HardBreak(ret, depth)
+ case ListNode:
+ return activeFormatter.List(ret, depth)
+ case ListItemNode:
+ return activeFormatter.ListItem(ret, depth)
+ case TextNode:
+ return activeFormatter.Text(n.content+ret, depth)
+ case StrongNode:
+ return activeFormatter.Strong(ret, depth)
+ case EmphasisNode:
+ return activeFormatter.Emphasis(ret, depth)
+ case SpecialNode:
+ return activeFormatter.Special(ret, n.level)
+ default:
+ return fmt.Sprintf(`[INVALID Node: type=%s depth=%d text=%s]`, n.Type(), depth, n.content+"/"+ret)
+ }
+}
diff --git a/internal/markdown/ast_test.go b/internal/markdown/ast_test.go
new file mode 100644
index 00000000..2359b476
--- /dev/null
+++ b/internal/markdown/ast_test.go
@@ -0,0 +1,94 @@
+package markdown
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ // Use ReMarkdown for consistent output
+ SetFormatter(ReMarkdown{})
+}
+
+func TestParser(t *testing.T) {
+ t.Run("Heading", func(t *testing.T) {
+ doc := NewParser("# Hello").Parse()
+ require.Equal(t, DocumentNode, doc.Type())
+ require.Len(t, doc.Children(), 1)
+ heading := doc.Children()[0]
+ require.Equal(t, HeadingNode, heading.Type())
+ require.Equal(t, "# Hello", doc.String(0))
+ })
+
+ t.Run("Paragraph", func(t *testing.T) {
+ doc := NewParser("Hello world").Parse()
+ require.Len(t, doc.Children(), 1)
+ para := doc.Children()[0]
+ require.Equal(t, ParagraphNode, para.Type())
+ require.Equal(t, "Hello world", doc.String(0))
+ })
+
+ t.Run("HorizontalLine", func(t *testing.T) {
+ doc := NewParser("---").Parse()
+ require.Len(t, doc.Children(), 1)
+ sep := doc.Children()[0]
+ require.Equal(t, HorizontalLineNode, sep.Type())
+ require.Equal(t, "---", doc.String(0))
+ })
+
+ t.Run("HardBreak", func(t *testing.T) {
+ doc := NewParser("Line one \nLine two").Parse()
+ require.Equal(t, "Line one\nLine two", doc.String(0))
+ })
+
+ t.Run("List", func(t *testing.T) {
+ doc := NewParser("- one\n- two").Parse()
+ require.Len(t, doc.Children(), 1)
+ list := doc.Children()[0]
+ require.Equal(t, ListNode, list.Type())
+ require.Equal(t, "- one\n- two", doc.String(0))
+ })
+
+ t.Run("InlineFormatting", func(t *testing.T) {
+ t.Run("Emphasis", func(t *testing.T) {
+ doc := NewParser("*em*").Parse()
+ require.Len(t, doc.Children(), 1)
+ para := doc.Children()[0].(*baseNode)
+ require.Equal(t, ParagraphNode, para.Type())
+ children := para.Children()
+ require.Len(t, children, 1)
+ require.Equal(t, EmphasisNode, children[0].Type())
+ require.Equal(t, "*em*", doc.String(0))
+ })
+
+ t.Run("Strong", func(t *testing.T) {
+ doc := NewParser("**bold**").Parse()
+ require.Len(t, doc.Children(), 1)
+ para := doc.Children()[0].(*baseNode)
+ require.Equal(t, ParagraphNode, para.Type())
+ children := para.Children()
+ require.Len(t, children, 1)
+ require.Equal(t, StrongNode, children[0].Type())
+ require.Equal(t, "**bold**", doc.String(0))
+ })
+
+ t.Run("Special", func(t *testing.T) {
+ doc := NewParser("~sp~").Parse()
+ require.Len(t, doc.Children(), 1)
+ para := doc.Children()[0].(*baseNode)
+ require.Equal(t, ParagraphNode, para.Type())
+ children := para.Children()
+ require.Len(t, children, 1)
+ require.Equal(t, SpecialNode, children[0].Type())
+ require.Equal(t, "~sp~", doc.String(0))
+ })
+ })
+
+ t.Run("InvalidNodeType", func(t *testing.T) {
+ n := &baseNode{nodeType: NodeType("Unknown"), content: "xyz"}
+ str := n.String(5)
+ require.Contains(t, str, "INVALID Node")
+ require.Contains(t, str, "xyz")
+ })
+}
diff --git a/internal/markdown/formatter.go b/internal/markdown/formatter.go
new file mode 100644
index 00000000..6f2fcb49
--- /dev/null
+++ b/internal/markdown/formatter.go
@@ -0,0 +1,15 @@
+package markdown
+
+type Formatter interface {
+ Document(string, int) string
+ Paragraph(string, int) string
+ HorizontalLine(string, int) string
+ HardBreak(string, int) string
+ Heading(string, int) string
+ List(string, int) string
+ ListItem(string, int) string
+ Text(string, int) string
+ Strong(string, int) string
+ Emphasis(string, int) string
+ Special(string, int) string
+}
diff --git a/internal/markdown/formatter_ansitags.go b/internal/markdown/formatter_ansitags.go
new file mode 100644
index 00000000..12f467f9
--- /dev/null
+++ b/internal/markdown/formatter_ansitags.go
@@ -0,0 +1,75 @@
+package markdown
+
+import (
+ "strconv"
+ "strings"
+)
+
+// Formats into HTML tags
+//
+// Expected ansitags color aliases:
+// md
+// md-p
+// md-h1-prefix
+// md-h1, md-h2, md-h3 etc.
+// md-li
+// md-bold
+// md-em
+// md-sp1, md-sp2, md-sp3, etc.
+// md-tbl-hdr
+// md-tbl-row
+// md-tbl-cell
+// md-hr1
+// md-hr2
+//
+// All have bg classes named the same with "-bg" at the end.
+// Example: md-li-bg
+
+var dividers = map[string]string{
+ "---": "\n--------------------------------------------------------------------------------",
+ "===": "\n================================================================================",
+ ":::": "\n .--. .-'. .--. .--. .--. .--. .`-. .--. \n" +
+ ":::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\:::\n" +
+ "' `--' `.-' `--' `--' `--' `-.' `--' `--",
+}
+
+type ANSITags struct{}
+
+func (ANSITags) Document(contents string, depth int) string {
+ return "" + strings.TrimLeft(contents, "\n ") + ""
+}
+func (ANSITags) Paragraph(contents string, depth int) string {
+ return "\n\n" + contents + ""
+}
+func (ANSITags) HorizontalLine(contents string, depth int) string {
+ return "\n" + dividers[contents]
+}
+func (ANSITags) HardBreak(contents string, depth int) string { return "\n" }
+func (ANSITags) Heading(contents string, depth int) string {
+ if depth == 1 {
+ contents = ".: " + contents
+ }
+ return "\n\n" + contents + ""
+}
+func (ANSITags) List(contents string, depth int) string {
+ if depth == 0 {
+ return "\n\n" + contents
+ }
+ return strings.Repeat(` `, depth) + contents
+}
+func (ANSITags) ListItem(contents string, depth int) string {
+ return "\n" + strings.Repeat(` `, depth) + "- " + contents + ""
+}
+func (ANSITags) Text(contents string, depth int) string {
+ //return strings.TrimSpace(contents)
+ return contents
+}
+func (ANSITags) Strong(contents string, depth int) string {
+ return "" + contents + ""
+}
+func (ANSITags) Emphasis(contents string, depth int) string {
+ return "" + contents + ""
+}
+func (ANSITags) Special(contents string, depth int) string {
+ return "" + contents + ""
+}
diff --git a/internal/markdown/formatter_html.go b/internal/markdown/formatter_html.go
new file mode 100644
index 00000000..3522059d
--- /dev/null
+++ b/internal/markdown/formatter_html.go
@@ -0,0 +1,36 @@
+package markdown
+
+import (
+ "strconv"
+ "strings"
+)
+
+//
+// Formats into HTML tags
+//
+
+type HTML struct{}
+
+func (HTML) Document(contents string, depth int) string {
+ return strings.TrimLeft(contents, "\n ")
+}
+func (HTML) Paragraph(contents string, depth int) string { return "\n\n" + contents + "\n
" }
+func (HTML) HorizontalLine(contents string, depth int) string { return "\n
\n" }
+func (HTML) HardBreak(contents string, depth int) string { return "\n
\n" }
+func (HTML) Heading(contents string, depth int) string {
+ return "\n" + contents + ""
+}
+func (HTML) List(contents string, depth int) string {
+ return "\n" + strings.Repeat("\t", depth) + "" + contents + "\n" + strings.Repeat("\t", depth) + "
"
+}
+func (HTML) ListItem(contents string, depth int) string {
+ return "\n" + strings.Repeat("\t", depth) + "" + contents + "\n" + strings.Repeat("\t", depth) + ""
+}
+func (HTML) Text(contents string, depth int) string {
+ return contents
+}
+func (HTML) Strong(contents string, depth int) string { return "" + contents + "" }
+func (HTML) Emphasis(contents string, depth int) string { return "" + contents + "" }
+func (HTML) Special(contents string, depth int) string {
+ return "" + contents + ""
+}
diff --git a/internal/markdown/formatter_remarkdown.go b/internal/markdown/formatter_remarkdown.go
new file mode 100644
index 00000000..61879ac6
--- /dev/null
+++ b/internal/markdown/formatter_remarkdown.go
@@ -0,0 +1,37 @@
+package markdown
+
+import "strings"
+
+//
+// Formats into a clean version of supported markdown
+//
+
+type ReMarkdown struct{}
+
+func (ReMarkdown) Document(contents string, depth int) string {
+ return strings.TrimLeft(contents, "\n ")
+}
+func (ReMarkdown) Paragraph(contents string, depth int) string { return "\n\n" + contents }
+func (ReMarkdown) HardBreak(contents string, depth int) string { return "\n" }
+func (ReMarkdown) HorizontalLine(contents string, depth int) string { return "\n\n---" }
+func (ReMarkdown) Heading(contents string, depth int) string {
+ return "\n\n" + strings.Repeat(`#`, depth) + " " + contents
+}
+func (ReMarkdown) List(contents string, depth int) string {
+ if depth == 0 {
+ return "\n\n" + contents
+ }
+ return strings.Repeat(` `, depth) + contents
+}
+func (ReMarkdown) ListItem(contents string, depth int) string {
+ return "\n" + strings.Repeat(` `, depth) + "- " + contents
+}
+func (ReMarkdown) Text(contents string, depth int) string {
+ //return strings.TrimSpace(contents)
+ return contents
+}
+func (ReMarkdown) Strong(contents string, depth int) string { return "**" + contents + "**" }
+func (ReMarkdown) Emphasis(contents string, depth int) string { return "*" + contents + "*" }
+func (ReMarkdown) Special(contents string, depth int) string {
+ return strings.Repeat(`~`, depth) + contents + strings.Repeat(`~`, depth)
+}
diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go
new file mode 100644
index 00000000..133b7610
--- /dev/null
+++ b/internal/markdown/markdown_test.go
@@ -0,0 +1,97 @@
+package markdown
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// Output oriented tests, for development
+
+// This test should be ran just as a way to verify content visually.
+// Prefixed with "x" when not being used.
+func xTest_Printing(t *testing.T) {
+ src := `# This is a **HEADING**
+
+
+This is a *NEW PARAGRAPH*.
+Paragraph a preceded by two new lines.
+This is a **line break**.
+Line breaks happen when the previous line ended.
+with two spaces: " ".
+They are preceded by only a single new line.
+
+This is another paragraph.
+
+- item one
+ - item one >> sub one
+ - item one >> sub two
+ - item one >> sub two >> sub one
+- **bold** item two
+
+## This is a ~~SUB HEADING~~
+
+ That ~is~ all.
+
+Some text
+---
+===
+:::
+Some more text
+
+ That ~is~ all.
+`
+
+ parser := NewParser(src)
+ ast := parser.Parse()
+
+ fmt.Println("------------------- DUMP -------------------")
+ fmt.Println()
+ Dump(ast, 3)
+ fmt.Println()
+ fmt.Println("----------------- REFORMAT -----------------")
+ fmt.Println()
+ fmt.Println(ast.String(0))
+ fmt.Println()
+
+ fmt.Println("------------------- HTML -------------------")
+ fmt.Println()
+ SetFormatter(HTML{})
+ fmt.Println(ast.String(0))
+ fmt.Println()
+ fmt.Println("------------------- ANSI -------------------")
+ fmt.Println()
+ SetFormatter(ANSITags{})
+ fmt.Println(ast.String(0))
+ fmt.Println()
+
+ fmt.Println("------------------- DONE -------------------")
+}
+
+// Useful for printing out stuff
+func Dump(n Node, indentSpaces int, currentIndent ...int) {
+
+ if len(currentIndent) == 0 {
+ currentIndent = []int{0}
+ }
+
+ if indentSpaces == 0 {
+ indentSpaces = 1
+ }
+
+ fmt.Printf("%s- %s", strings.Repeat(" ", currentIndent[0]*indentSpaces), n.Type())
+
+ bNode := n.(*baseNode)
+ switch n.Type() {
+ case HeadingNode:
+ fmt.Printf(" (level=%d)\n", bNode.level)
+ case TextNode:
+ fmt.Printf(": %q\n", bNode.content)
+ default:
+ fmt.Printf(" (%d)", len(n.Children()))
+ fmt.Println()
+ }
+ for _, c := range n.Children() {
+ Dump(c, indentSpaces, currentIndent[0]+1)
+ }
+}
diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go
new file mode 100644
index 00000000..10a7e9e7
--- /dev/null
+++ b/internal/markdown/parser.go
@@ -0,0 +1,256 @@
+package markdown
+
+import (
+ "regexp"
+ "strings"
+)
+
+const (
+ lineBreakString = " "
+)
+
+var tableSep = regexp.MustCompile(`^\s*\|?[-: ]+\|?([-: ]*\|?)*\s*$`)
+
+type Parser struct {
+ lines []string
+ pos int
+}
+
+func NewParser(input string) *Parser {
+ return &Parser{
+ lines: strings.Split(input, "\n"),
+ }
+}
+
+func (p *Parser) Parse() Node {
+ doc := &baseNode{nodeType: DocumentNode}
+ for p.pos < len(p.lines) {
+ line := p.lines[p.pos]
+
+ switch {
+ case strings.HasPrefix(line, "---"), strings.HasPrefix(line, "==="), strings.HasPrefix(line, ":::"):
+ doc.nodeChildren = append(doc.nodeChildren, p.parseHorizontalLine())
+ case strings.HasPrefix(line, "#"):
+ doc.nodeChildren = append(doc.nodeChildren, p.parseHeading())
+ case strings.HasPrefix(strings.TrimSpace(line), "- "):
+ // compute leading-space indent
+ indent := len(line) - len(strings.TrimLeft(line, " "))
+ doc.nodeChildren = append(doc.nodeChildren, p.parseList(indent))
+ case strings.TrimSpace(line) == "":
+ p.pos++ // skip blank
+ default:
+ // instead of a single node, grab a slice
+ for _, node := range p.parseParagraphNodes() {
+ doc.nodeChildren = append(doc.nodeChildren, node)
+ }
+ }
+ }
+ return doc
+}
+
+func (p *Parser) parseHorizontalLine() *baseNode {
+ line := p.lines[p.pos]
+ level := 0
+
+ lineType := line[level]
+ for level < len(line) && line[level] == lineType {
+ level++
+ }
+ // skip "# " prefix
+ content := ""
+ if len(line) > level+1 {
+ content = line[level+1:]
+ }
+ p.pos++
+
+ h := &baseNode{
+ nodeType: HorizontalLineNode,
+ nodeChildren: p.parseInline(content),
+ level: level,
+ content: strings.Repeat(string(lineType), 3),
+ }
+ return h
+}
+
+func (p *Parser) parseHeading() *baseNode {
+ line := p.lines[p.pos]
+ level := 0
+ for level < len(line) && line[level] == '#' {
+ level++
+ }
+ // skip "# " prefix
+ content := ""
+ if len(line) > level+1 {
+ content = line[level+1:]
+ }
+ p.pos++
+
+ h := &baseNode{
+ nodeType: HeadingNode,
+ nodeChildren: p.parseInline(content),
+ level: level,
+ }
+ return h
+}
+
+// parseList now takes the indent level of its bullets
+func (p *Parser) parseList(baseIndent int) *baseNode {
+ list := &baseNode{nodeType: ListNode}
+
+ for p.pos < len(p.lines) {
+ line := p.lines[p.pos]
+ // count leading spaces
+ currIndent := len(line) - len(strings.TrimLeft(line, " "))
+ trimmed := strings.TrimSpace(line)
+
+ // if it’s not a bullet or we’ve de-indented, this list is done
+ if !strings.HasPrefix(trimmed, "- ") || currIndent < baseIndent {
+ break
+ }
+
+ if currIndent > baseIndent {
+ // nested list: recurse, attach to last ListItem
+ nested := p.parseList(currIndent)
+ if len(list.nodeChildren) > 0 {
+ lastItem := list.nodeChildren[len(list.nodeChildren)-1].(*baseNode)
+ lastItem.nodeChildren = append(lastItem.nodeChildren, nested)
+ }
+ continue
+ }
+
+ // same-level item
+ itemText := trimmed[2:] // drop "- "
+ item := &baseNode{nodeType: ListItemNode}
+ item.nodeChildren = p.parseInline(itemText)
+ list.nodeChildren = append(list.nodeChildren, item)
+ p.pos++
+ }
+
+ return list
+}
+
+func (p *Parser) parseParagraphNodes() []Node {
+ // 1) collect until blank line
+ var lines []string
+ for p.pos < len(p.lines) && strings.TrimSpace(p.lines[p.pos]) != "" && !strings.HasPrefix(strings.TrimSpace(p.lines[p.pos]), `---`) {
+ lines = append(lines, p.lines[p.pos])
+ p.pos++
+ }
+
+ var nodes []Node
+ start := 0
+
+ // 2) whenever we see a line ending in " ",
+ // that's a hard break point.
+ para := &baseNode{nodeType: ParagraphNode}
+
+ for i, line := range lines {
+ if strings.HasSuffix(line, lineBreakString) {
+ // lines[start..i] form one paragraph
+ seg := append([]string{}, lines[start:i+1]...)
+ seg[len(seg)-1] = strings.TrimSuffix(seg[len(seg)-1], lineBreakString)
+
+ // Parse the contents thus far, converting new lines into spaces
+ newChildren := p.parseInline(strings.Join(seg, " "))
+ // add to paragraph
+ para.nodeChildren = append(para.nodeChildren, newChildren...)
+ // now add a line break
+ para.nodeChildren = append(para.nodeChildren, &baseNode{nodeType: HardBreakNode})
+
+ // now skip ahead
+ start = i + 1
+ continue
+ }
+ }
+
+ // 3) whatever remains after the last hard-break
+ if start < len(lines) {
+ seg := lines[start:]
+ newChildren := p.parseInline(strings.Join(seg, " "))
+ para.nodeChildren = append(para.nodeChildren, newChildren...)
+ }
+
+ nodes = append(nodes, para)
+ return nodes
+}
+
+func (p *Parser) parseInline(text string) []Node {
+ var nodes []Node
+ for i := 0; i < len(text); {
+ // —— special: ~…~
+ if text[i] == '~' {
+ start := i
+ for i < len(text) && text[i] == '~' {
+ i++
+ }
+ count := i - start
+ delim := strings.Repeat("~", count)
+
+ if j := strings.Index(text[i:], delim); j >= 0 {
+ inner := text[i : i+j]
+ childNodes := p.parseInline(inner)
+ n := &baseNode{
+ nodeType: SpecialNode,
+ nodeChildren: childNodes,
+ level: count,
+ }
+ nodes = append(nodes, n)
+ i += j + count
+ continue
+ }
+
+ // no closing run → literal dollars
+ nodes = append(nodes, &baseNode{
+ nodeType: TextNode,
+ content: text[start:i],
+ })
+ continue
+ }
+
+ // —— strong: **bold**
+ if strings.HasPrefix(text[i:], "**") && i+2 < len(text) && text[i+2] != ' ' {
+ if j := strings.Index(text[i+2:], "**"); j >= 0 {
+ inner := text[i+2 : i+2+j]
+ n := &baseNode{nodeType: StrongNode}
+ n.nodeChildren = p.parseInline(inner)
+ nodes = append(nodes, n)
+ i += 4 + j
+ continue
+ }
+ }
+
+ // —— emphasis: *em*
+ if text[i] == '*' && i+1 < len(text) && text[i+1] != ' ' {
+ if j := strings.Index(text[i+1:], "*"); j >= 0 {
+ inner := text[i+1 : i+1+j]
+ n := &baseNode{nodeType: EmphasisNode}
+ n.nodeChildren = p.parseInline(inner)
+ nodes = append(nodes, n)
+ i += 2 + j
+ continue
+ }
+ }
+
+ // —— plain text fallback
+ j := i
+ for j < len(text) && text[j] != '*' && text[j] != '~' {
+ j++
+ }
+ if j == i {
+ // unmatched '*' or '~', consume one char
+ nodes = append(nodes, &baseNode{
+ nodeType: TextNode,
+ content: text[i : i+1],
+ })
+ i++
+ } else {
+ // real plain text
+ nodes = append(nodes, &baseNode{
+ nodeType: TextNode,
+ content: text[i:j],
+ })
+ i = j
+ }
+ }
+ return nodes
+}
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
index 88ca4ead..ab1c3cf2 100644
--- a/internal/templates/templates.go
+++ b/internal/templates/templates.go
@@ -14,6 +14,7 @@ import (
"github.com/GoMudEngine/GoMud/internal/colorpatterns"
"github.com/GoMudEngine/GoMud/internal/configs"
"github.com/GoMudEngine/GoMud/internal/fileloader"
+ "github.com/GoMudEngine/GoMud/internal/markdown"
"github.com/GoMudEngine/GoMud/internal/mudlog"
"github.com/GoMudEngine/GoMud/internal/users"
"github.com/GoMudEngine/ansitags"
@@ -33,6 +34,10 @@ const (
AnsiTagsNone = AnsiTagsDefault // alias to default
ForceScreenReaderUserId = -1
+
+ divider = "\n .--. .-'. .--. .--. .--. .--. .`-. .--.\n" +
+ ":::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\::::::::.\\\n" +
+ "' `--' `.-' `--' `--' `--' `-.' `--' `\n"
)
type cacheEntry struct {
@@ -100,14 +105,21 @@ type templateConfig struct {
}
type templateDetails struct {
- name string
- path string
+ name string
+ path string
+ preProcess func(string) string
}
func ClearTemplateConfigCache(userId int) {
delete(templateConfigCache, userId)
}
+func processMarkdown(in string) string {
+ markdown.SetFormatter(markdown.ANSITags{})
+ p := markdown.NewParser(in)
+ return "\n" + divider + "\n" + p.Parse().String(0) + "\n"
+}
+
func Process(fname string, data any, receivingUserId ...int) (string, error) {
ansiLock.RLock()
defer ansiLock.RUnlock()
@@ -157,6 +169,14 @@ func Process(fname string, data any, receivingUserId ...int) (string, error) {
)
}
+ filesToAttempt = append(filesToAttempt,
+ templateDetails{
+ name: fname,
+ path: util.FilePath(`templates/`, fname+`.md`), // All templates must end with .template
+ preProcess: processMarkdown,
+ },
+ )
+
filesToAttempt = append(filesToAttempt,
templateDetails{
name: fname,
@@ -183,6 +203,10 @@ func Process(fname string, data any, receivingUserId ...int) (string, error) {
if parseAnsiTags {
return ansitags.Parse(buf.String(), ansitagsParseBehavior...), nil
}
+
+ if tplInfo.preProcess != nil {
+ return tplInfo.preProcess(buf.String()), nil
+ }
return buf.String(), nil
}
@@ -226,6 +250,10 @@ func Process(fname string, data any, receivingUserId ...int) (string, error) {
if parseAnsiTags {
return ansitags.Parse(buf.String(), ansitagsParseBehavior...), nil
}
+
+ if tplInfo.preProcess != nil {
+ return tplInfo.preProcess(buf.String()), nil
+ }
return buf.String(), nil
}