209 lines
6.2 KiB
Go
209 lines
6.2 KiB
Go
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
|
|
}
|