type.go

  1package document
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"log/slog"
  7	"strings"
  8)
  9
 10type NodeTypes string
 11
 12var (
 13	Paragraph   NodeTypes = "paragraph"
 14	Heading     NodeTypes = "heading"
 15	Text        NodeTypes = "text"
 16	Waypoint    NodeTypes = "waypoint"
 17	OrderedList NodeTypes = "orderedList"
 18	ListItem    NodeTypes = "listItem"
 19	BulletList  NodeTypes = "bulletList"
 20)
 21
 22type DocumentContent struct {
 23	Main []Node `json:"main"`
 24}
 25
 26type Node struct {
 27	Type     NodeTypes       `json:"type"`
 28	Children []Node          `json:"children"`
 29	Attrs    json.RawMessage `json:"attrs"`
 30	Content  string          `json:"content,omitempty"` //only for type=text
 31}
 32
 33type HeadingAttrs struct {
 34	Level int `json:"level"`
 35}
 36
 37func NodeToAttrs[T any](n Node) T {
 38	var attrs T
 39	if err := json.Unmarshal(n.Attrs, &attrs); err != nil {
 40		slog.Error("unexpected error rendering page", "node_type", n.Type, "attrs_type", fmt.Sprintf("%T", attrs), "error", err)
 41	}
 42	return attrs
 43}
 44
 45// WaypointAttrs are attributes for type=waypoint
 46type WaypointAttrs struct {
 47	Cons        []string         `json:"cons"`
 48	Country     string           `json:"country"`
 49	From        map[string]Route `json:"from"`
 50	Gid         string           `json:"gid"`
 51	ID          string           `json:"id"`
 52	Label       string           `json:"label"`
 53	Nonroutable bool             `json:"nonroutable"`
 54	Point       []float64        `json:"point"`
 55}
 56
 57func NodeToWaypointAttrs(n Node) (WaypointAttrs, error) {
 58	var attrs WaypointAttrs
 59	if err := json.Unmarshal(n.Attrs, &attrs); err != nil {
 60		return attrs, err
 61	}
 62	return attrs, nil
 63}
 64
 65type WaypointContent map[string]string
 66
 67// NodeToWaypointContent coerces a node into a waypoint content where attribute values are all strings
 68func NodeToWaypointContent(n Node) WaypointContent {
 69	var attrs map[string]any
 70	json.Unmarshal(n.Attrs, &attrs)
 71	out := make(map[string]string)
 72	for k, v := range attrs {
 73		switch value := v.(type) {
 74		case string:
 75			out[k] = value
 76		default:
 77			v2, _ := json.Marshal(v)
 78			out[k] = string(v2)
 79		}
 80	}
 81	return out
 82}
 83
 84type Route struct {
 85	Bbox        []float64 `json:"bbox"`
 86	Distance    float64   `json:"d"`
 87	TimeSeconds float64   `json:"t"`
 88	ID          string    `json:"id"`
 89}
 90
 91type OrderedListAttrs struct {
 92	Start int `json:"start"`
 93}
 94
 95type TextType uint8
 96
 97const (
 98	TextBold TextType = 1 << iota
 99	TextUnderline
100	TextItalic
101)
102
103// TextContent is only valid for type=text
104type TextContent struct {
105	Type    TextType `json:"type"`
106	Content string   `json:"content"`
107}
108
109var (
110	textTypeOpeningTags = map[string]TextType{
111		"<bold>":      TextBold,
112		"<underline>": TextUnderline,
113		"<italic>":    TextItalic,
114	}
115	textTypeClosingTags = map[string]TextType{
116		"</bold>":      TextBold,
117		"</underline>": TextUnderline,
118		"</italic>":    TextItalic,
119	}
120)
121
122// Classes returns a space-separated list of CSS class names for the formatting bits set.
123func (t TextType) Classes() string {
124	var parts []string
125	if t&TextBold != 0 {
126		parts = append(parts, "bold")
127	}
128	if t&TextUnderline != 0 {
129		parts = append(parts, "underline")
130	}
131	if t&TextItalic != 0 {
132		parts = append(parts, "italic")
133	}
134	return strings.Join(parts, " ")
135}
136
137// NodeToTextContent parses a node's Content string for XML-like formatting tags
138// and returns a slice of TextContent segments. Best-effort: unmatched closing
139// tags are ignored; unclosed opening tags apply to the remainder.
140func NodeToTextContent(n Node) []TextContent {
141	if n.Type != Text {
142		return nil
143	}
144	content := n.Content
145
146	var result []TextContent
147	var stack []TextType
148	var currentMask TextType
149	var buf strings.Builder
150
151	flush := func() {
152		if buf.Len() > 0 {
153			result = append(result, TextContent{
154				Type:    currentMask,
155				Content: buf.String(),
156			})
157			buf.Reset()
158		}
159	}
160
161	for i := 0; i < len(content); {
162		if content[i] == '<' {
163			tagEnd := strings.IndexByte(content[i:], '>')
164			if tagEnd == -1 {
165				buf.WriteString(content[i:])
166				break
167			}
168			tagEnd += i
169			tag := content[i : tagEnd+1]
170
171			if tt, ok := textTypeOpeningTags[tag]; ok {
172				flush()
173				stack = append(stack, tt)
174				currentMask |= tt
175				i = tagEnd + 1
176				continue
177			}
178
179			if tt, ok := textTypeClosingTags[tag]; ok {
180				flush()
181				if len(stack) > 0 && stack[len(stack)-1] == tt {
182					stack = stack[:len(stack)-1]
183					currentMask = 0
184					for _, st := range stack {
185						currentMask |= st
186					}
187				}
188				i = tagEnd + 1
189				continue
190			}
191
192			// Unknown tag – treat '<' as literal text
193			buf.WriteByte('<')
194			i++
195		} else {
196			buf.WriteByte(content[i])
197			i++
198		}
199	}
200
201	flush()
202	return result
203}