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}