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 }