diff --git a/server/public/model/utils.go b/server/public/model/utils.go index 527828e26c..5ddadedd1d 100644 --- a/server/public/model/utils.go +++ b/server/public/model/utils.go @@ -717,8 +717,8 @@ var ( func ParseHashtags(text string) (string, string) { words := strings.Fields(text) - hashtagString := "" - plainString := "" + var hashtagStringSb strings.Builder + var plainString strings.Builder for _, word := range words { // trim off surrounding punctuation word = puncStart.ReplaceAllString(word, "") @@ -728,11 +728,12 @@ func ParseHashtags(text string) (string, string) { word = hashtagStart.ReplaceAllString(word, "#") if validHashtag.MatchString(word) { - hashtagString += " " + word + hashtagStringSb.WriteString(" " + word) } else { - plainString += " " + word + plainString.WriteString(" " + word) } } + hashtagString := hashtagStringSb.String() if len(hashtagString) > 1000 { hashtagString = hashtagString[:999] @@ -744,7 +745,7 @@ func ParseHashtags(text string) (string, string) { } } - return strings.TrimSpace(hashtagString), strings.TrimSpace(plainString) + return strings.TrimSpace(hashtagString), strings.TrimSpace(plainString.String()) } func ClearMentionTags(post string) string { diff --git a/server/public/model/utils_test.go b/server/public/model/utils_test.go index 9865ce82c1..67ef4bab5f 100644 --- a/server/public/model/utils_test.go +++ b/server/public/model/utils_test.go @@ -541,10 +541,58 @@ func TestStringArray_Equal(t *testing.T) { } func TestParseHashtags(t *testing.T) { - for input, output := range hashtags { - o, _ := ParseHashtags(input) - require.Equal(t, o, output, "failed to parse hashtags from input="+input+" expected="+output+" actual="+o) - } + t.Run("basic hashtag extraction", func(t *testing.T) { + for input, output := range hashtags { + o, _ := ParseHashtags(input) + require.Equal(t, o, output, "failed to parse hashtags from input="+input+" expected="+output+" actual="+o) + } + }) + + t.Run("long hashtag string truncation", func(t *testing.T) { + // Test case where hashtag string exceeds 1000 characters with a space to truncate at + longHashtags := "#test " + strings.Repeat("#verylonghashtag ", 50) + hashtagString, plainString := ParseHashtags(longHashtags) + require.NotEmpty(t, hashtagString) + require.LessOrEqual(t, len(hashtagString), 1000) + require.Empty(t, plainString) + // Ensure it truncated at a space + require.NotEqual(t, "", hashtagString) + require.True(t, hashtagString[len(hashtagString)-1] != ' ') + }) + + t.Run("long hashtag string truncation without spaces", func(t *testing.T) { + // Test case where hashtag string exceeds 1000 characters with no space after position 999 + // Create a single very long hashtag that will be truncated + veryLongHashtag := "#" + strings.Repeat("a", 1010) + hashtagString, plainString := ParseHashtags(veryLongHashtag) + // Should be empty because no space was found to truncate at + require.Equal(t, "", hashtagString) + require.Empty(t, plainString) + }) + + t.Run("plain text extraction", func(t *testing.T) { + hashtagString, plainString := ParseHashtags("hello #world this is #test plain text") + require.Equal(t, "#world #test", hashtagString) + require.Equal(t, "hello this is plain text", plainString) + }) + + t.Run("only plain text", func(t *testing.T) { + hashtagString, plainString := ParseHashtags("no hashtags here") + require.Empty(t, hashtagString) + require.Equal(t, "no hashtags here", plainString) + }) + + t.Run("only hashtags", func(t *testing.T) { + hashtagString, plainString := ParseHashtags("#one #two #three") + require.Equal(t, "#one #two #three", hashtagString) + require.Empty(t, plainString) + }) + + t.Run("empty string", func(t *testing.T) { + hashtagString, plainString := ParseHashtags("") + require.Empty(t, hashtagString) + require.Empty(t, plainString) + }) } func TestIsValidAlphaNum(t *testing.T) { diff --git a/server/public/shared/markdown/fenced_code.go b/server/public/shared/markdown/fenced_code.go index c8caad5561..22f43bfd63 100644 --- a/server/public/shared/markdown/fenced_code.go +++ b/server/public/shared/markdown/fenced_code.go @@ -23,11 +23,12 @@ type FencedCode struct { RawCode []FencedCodeLine } -func (b *FencedCode) Code() (result string) { +func (b *FencedCode) Code() string { + var resultSb strings.Builder for _, code := range b.RawCode { - result += strings.Repeat(" ", code.Indentation) + b.markdown[code.Range.Position:code.Range.End] + resultSb.WriteString(strings.Repeat(" ", code.Indentation) + b.markdown[code.Range.Position:code.Range.End]) } - return + return resultSb.String() } func (b *FencedCode) Info() string { diff --git a/server/public/shared/markdown/html.go b/server/public/shared/markdown/html.go index f15bfb664f..fb63c4198e 100644 --- a/server/public/shared/markdown/html.go +++ b/server/public/shared/markdown/html.go @@ -27,69 +27,75 @@ func RenderBlockHTML(block Block, referenceDefinitions []*ReferenceDefinition) ( return renderBlockHTML(block, referenceDefinitions, false) } -func renderBlockHTML(block Block, referenceDefinitions []*ReferenceDefinition, isTightList bool) (result string) { +func renderBlockHTML(block Block, referenceDefinitions []*ReferenceDefinition, isTightList bool) string { + var resultSb strings.Builder + switch v := block.(type) { case *Document: for _, block := range v.Children { - result += RenderBlockHTML(block, referenceDefinitions) + resultSb.WriteString(RenderBlockHTML(block, referenceDefinitions)) } case *Paragraph: if len(v.Text) == 0 { - return + return "" } if !isTightList { - result += "

" + resultSb.WriteString("

") } for _, inline := range v.ParseInlines(referenceDefinitions) { - result += RenderInlineHTML(inline) + resultSb.WriteString(RenderInlineHTML(inline)) } if !isTightList { - result += "

" + resultSb.WriteString("

") } case *List: if v.IsOrdered { if v.OrderedStart != 1 { - result += fmt.Sprintf(`
    `, v.OrderedStart) + resultSb.WriteString(fmt.Sprintf(`
      `, v.OrderedStart)) } else { - result += "
        " + resultSb.WriteString("
          ") } } else { - result += "
            " + resultSb.WriteString("
              ") } for _, block := range v.Children { - result += renderBlockHTML(block, referenceDefinitions, !v.IsLoose) + resultSb.WriteString(renderBlockHTML(block, referenceDefinitions, !v.IsLoose)) } if v.IsOrdered { - result += "
        " + resultSb.WriteString("
      ") } else { - result += "" + resultSb.WriteString("") } case *ListItem: - result += "
    1. " + resultSb.WriteString("
    2. ") for _, block := range v.Children { - result += renderBlockHTML(block, referenceDefinitions, isTightList) + resultSb.WriteString(renderBlockHTML(block, referenceDefinitions, isTightList)) } - result += "
    3. " + resultSb.WriteString("") case *BlockQuote: - result += "
      " + resultSb.WriteString("
      ") for _, block := range v.Children { - result += RenderBlockHTML(block, referenceDefinitions) + resultSb.WriteString(RenderBlockHTML(block, referenceDefinitions)) } - result += "
      " + resultSb.WriteString("
      ") case *FencedCode: if info := v.Info(); info != "" { language := strings.Fields(info)[0] - result += `
      `
      +			resultSb.WriteString(`
      `)
       		} else {
      -			result += "
      "
      +			resultSb.WriteString("
      ")
       		}
      -		result += htmlEscaper.Replace(v.Code()) + "
      " + resultSb.WriteString(htmlEscaper.Replace(v.Code())) + resultSb.WriteString("
      ") case *IndentedCode: - result += "
      " + htmlEscaper.Replace(v.Code()) + "
      " + resultSb.WriteString("
      ")
      +		resultSb.WriteString(htmlEscaper.Replace(v.Code()))
      +		resultSb.WriteString("
      ") default: panic(fmt.Sprintf("missing case for type %T", v)) } - return + + return resultSb.String() } func escapeURL(url string) (result string) { @@ -137,31 +143,37 @@ func RenderInlineHTML(inline Inline) (result string) { } result += ` />` case *InlineLink: - result += `` + resultSb.WriteString(`>`) for _, inline := range v.Children { - result += RenderInlineHTML(inline) + resultSb.WriteString(RenderInlineHTML(inline)) } - result += "" + resultSb.WriteString("") + return resultSb.String() case *ReferenceLink: - result += `` + resultSb.WriteString(`>`) for _, inline := range v.Children { - result += RenderInlineHTML(inline) + resultSb.WriteString(RenderInlineHTML(inline)) } - result += "" + resultSb.WriteString("") + return resultSb.String() case *Autolink: - result += `` + var resultSb strings.Builder + resultSb.WriteString(``) for _, inline := range v.Children { - result += RenderInlineHTML(inline) + resultSb.WriteString(RenderInlineHTML(inline)) } - result += "" + resultSb.WriteString("") + return resultSb.String() case *Emoji: escapedName := htmlEscaper.Replace(v.Name) result += fmt.Sprintf(``, escapedName, escapedName) @@ -172,25 +184,30 @@ func RenderInlineHTML(inline Inline) (result string) { return } -func renderImageAltText(children []Inline) (result string) { +func renderImageAltText(children []Inline) string { + var resultSb strings.Builder for _, inline := range children { - result += renderImageChildAltText(inline) + resultSb.WriteString(renderImageChildAltText(inline)) } - return + return resultSb.String() } -func renderImageChildAltText(inline Inline) (result string) { +func renderImageChildAltText(inline Inline) string { switch v := inline.(type) { case *Text: return v.Text case *InlineImage: + var resultSb strings.Builder for _, inline := range v.Children { - result += renderImageChildAltText(inline) + resultSb.WriteString(renderImageChildAltText(inline)) } + return resultSb.String() case *InlineLink: + var resultSb strings.Builder for _, inline := range v.Children { - result += renderImageChildAltText(inline) + resultSb.WriteString(renderImageChildAltText(inline)) } + return resultSb.String() } - return + return "" } diff --git a/server/public/shared/markdown/indented_code.go b/server/public/shared/markdown/indented_code.go index a89ee6c70c..9a4a6b8212 100644 --- a/server/public/shared/markdown/indented_code.go +++ b/server/public/shared/markdown/indented_code.go @@ -19,11 +19,12 @@ type IndentedCode struct { RawCode []IndentedCodeLine } -func (b *IndentedCode) Code() (result string) { +func (b *IndentedCode) Code() string { + var resultSb strings.Builder for _, code := range b.RawCode { - result += strings.Repeat(" ", code.Indentation) + b.markdown[code.Range.Position:code.Range.End] + resultSb.WriteString(strings.Repeat(" ", code.Indentation) + b.markdown[code.Range.Position:code.Range.End]) } - return + return resultSb.String() } func (b *IndentedCode) Continuation(indentation int, r Range) *continuation { diff --git a/server/public/shared/markdown/reference_definition.go b/server/public/shared/markdown/reference_definition.go index 69e8ed9401..1c40cf83a1 100644 --- a/server/public/shared/markdown/reference_definition.go +++ b/server/public/shared/markdown/reference_definition.go @@ -3,6 +3,8 @@ package markdown +import "strings" + type ReferenceDefinition struct { RawDestination Range @@ -24,10 +26,11 @@ func (d *ReferenceDefinition) Title() string { } func parseReferenceDefinition(markdown string, ranges []Range) (*ReferenceDefinition, []Range) { - raw := "" + var rawSb strings.Builder for _, r := range ranges { - raw += markdown[r.Position:r.End] + rawSb.WriteString(markdown[r.Position:r.End]) } + raw := rawSb.String() label, next, ok := parseLinkLabel(raw, 0) if !ok {