feat: add chat
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Message formatting.
|
||||
//
|
||||
// The composer accepts three inline markers — *bold*, ~strike~, `code` — and
|
||||
// auto-detects links. Rather than store markup, we store CLEAN text plus a list
|
||||
// of spans describing what to decorate. Two reasons: rendering spans can't
|
||||
// inject HTML no matter what a user types, and every client (web today, native
|
||||
// later) applies them natively instead of parsing a markup dialect.
|
||||
//
|
||||
// OFFSETS ARE UTF-16 CODE UNITS, not bytes and not runes. JavaScript strings
|
||||
// are UTF-16, so `body.slice(offset, offset+length)` in the client only lines
|
||||
// up if we count the same way it does. This matters constantly here: Devanāgarī
|
||||
// and IAST are multi-byte, and emoji are surrogate pairs that count as 2.
|
||||
|
||||
// EntityType values understood by the renderer.
|
||||
const (
|
||||
EntityBold = "bold"
|
||||
EntityStrike = "strike"
|
||||
EntityCode = "code"
|
||||
EntityURL = "url"
|
||||
)
|
||||
|
||||
// Entity marks a formatted span of a message body.
|
||||
type Entity struct {
|
||||
Type string `json:"type"`
|
||||
Offset int `json:"offset"` // in UTF-16 code units
|
||||
Length int `json:"length"` // in UTF-16 code units
|
||||
// URL carries the resolved href for EntityURL spans, so "www.x.org" in the
|
||||
// text becomes a usable "https://www.x.org" without the client guessing.
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// markers maps the opening/closing rune to the entity it produces. Code is
|
||||
// handled with the same table but its contents are treated as literal.
|
||||
var markers = map[rune]string{
|
||||
'*': EntityBold,
|
||||
'~': EntityStrike,
|
||||
'`': EntityCode,
|
||||
}
|
||||
|
||||
// urlRe matches http(s) URLs and bare www. hosts. Deliberately conservative:
|
||||
// matching every bare domain turns "e.g" and "Bg 2.13" into links.
|
||||
var urlRe = regexp.MustCompile(`(?i)(?:https?://|www\.)[^\s<>()\[\]{}"']+`)
|
||||
|
||||
// trailing punctuation that is far more likely to be sentence punctuation than
|
||||
// part of the URL.
|
||||
const urlTrailing = `.,;:!?'"`
|
||||
|
||||
// ParseEntities converts raw composer text into clean body text plus the spans
|
||||
// describing its formatting. Markers are removed from the body — what the
|
||||
// reader copies is the text, not the syntax.
|
||||
func ParseEntities(raw string) (string, []Entity) {
|
||||
var b strings.Builder
|
||||
entities, _ := parseInto(&b, raw, 0)
|
||||
body := b.String()
|
||||
|
||||
// Links are detected last, over the finished body, so offsets need no
|
||||
// adjustment. Code spans are skipped: a URL inside `backticks` is being
|
||||
// shown, not linked.
|
||||
entities = append(entities, detectURLs(body, entities)...)
|
||||
sortEntities(entities)
|
||||
return body, entities
|
||||
}
|
||||
|
||||
// parseInto appends the cleaned text of raw to b and returns its entities plus
|
||||
// how many UTF-16 units were written. Returning the written count (rather than
|
||||
// deriving it from the input) keeps offsets correct however many markers a
|
||||
// nested parse strips.
|
||||
func parseInto(b *strings.Builder, raw string, base int) (entities []Entity, written int) {
|
||||
runes := []rune(raw)
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
kind, isMarker := markers[r]
|
||||
if !isMarker {
|
||||
b.WriteRune(r)
|
||||
written += utf16Len(r)
|
||||
continue
|
||||
}
|
||||
|
||||
close := findClose(runes, i)
|
||||
if close < 0 {
|
||||
// An unmatched marker is just a character the user typed.
|
||||
b.WriteRune(r)
|
||||
written += utf16Len(r)
|
||||
continue
|
||||
}
|
||||
|
||||
inner := string(runes[i+1 : close])
|
||||
start := base + written
|
||||
|
||||
if kind == EntityCode {
|
||||
// Code is literal: markers inside it are content, not syntax.
|
||||
b.WriteString(inner)
|
||||
written += utf16LenString(inner)
|
||||
} else {
|
||||
// Recurse so *bold with `code` inside* works.
|
||||
nested, n := parseInto(b, inner, start)
|
||||
entities = append(entities, nested...)
|
||||
written += n
|
||||
}
|
||||
|
||||
entities = append(entities, Entity{Type: kind, Offset: start, Length: base + written - start})
|
||||
i = close
|
||||
}
|
||||
return entities, written
|
||||
}
|
||||
|
||||
// findClose returns the index of the marker closing the one at open, or -1.
|
||||
//
|
||||
// The rules mirror what chat users expect: an opener must start a word and be
|
||||
// followed by content, a closer must end one. Without them "2*3*4" and
|
||||
// "a_b_c"-style text would silently turn into formatting.
|
||||
func findClose(runes []rune, open int) int {
|
||||
marker := runes[open]
|
||||
if open > 0 && isWordRune(runes[open-1]) {
|
||||
return -1 // mid-word: not an opener
|
||||
}
|
||||
if open+1 >= len(runes) || unicode.IsSpace(runes[open+1]) || runes[open+1] == marker {
|
||||
return -1 // empty or space-led content
|
||||
}
|
||||
for i := open + 1; i < len(runes); i++ {
|
||||
if runes[i] != marker {
|
||||
continue
|
||||
}
|
||||
if unicode.IsSpace(runes[i-1]) {
|
||||
continue // closer must follow content
|
||||
}
|
||||
if i+1 < len(runes) && isWordRune(runes[i+1]) {
|
||||
continue // mid-word: not a closer
|
||||
}
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// detectURLs finds links in body, skipping anything already inside a code span.
|
||||
func detectURLs(body string, existing []Entity) []Entity {
|
||||
var out []Entity
|
||||
for _, loc := range urlRe.FindAllStringIndex(body, -1) {
|
||||
text := strings.TrimRight(body[loc[0]:loc[1]], urlTrailing)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
offset := utf16LenString(body[:loc[0]])
|
||||
length := utf16LenString(text)
|
||||
if insideCode(existing, offset) {
|
||||
continue
|
||||
}
|
||||
href := text
|
||||
if !strings.Contains(href, "://") {
|
||||
href = "https://" + href
|
||||
}
|
||||
out = append(out, Entity{Type: EntityURL, Offset: offset, Length: length, URL: href})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func insideCode(entities []Entity, offset int) bool {
|
||||
for _, e := range entities {
|
||||
if e.Type == EntityCode && offset >= e.Offset && offset < e.Offset+e.Length {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sortEntities orders by offset, then by longer span first, so a renderer can
|
||||
// open outer decorations before the ones nested inside them.
|
||||
func sortEntities(entities []Entity) {
|
||||
for i := 1; i < len(entities); i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
a, b := entities[j-1], entities[j]
|
||||
if a.Offset < b.Offset || (a.Offset == b.Offset && a.Length >= b.Length) {
|
||||
break
|
||||
}
|
||||
entities[j-1], entities[j] = b, a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// utf16Len returns how many UTF-16 code units a rune occupies: 2 for anything
|
||||
// outside the Basic Multilingual Plane (emoji), 1 otherwise.
|
||||
func utf16Len(r rune) int {
|
||||
if r > 0xFFFF {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func utf16LenString(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
n += utf16Len(r)
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user