diff --git a/modules/markup/file_preview.go b/modules/markup/file_preview.go
new file mode 100644
index 000000000..646bf8363
--- /dev/null
+++ b/modules/markup/file_preview.go
@@ -0,0 +1,269 @@
+package markup
+
+import (
+	"bytes"
+	"html/template"
+	"regexp"
+	"slices"
+	"strconv"
+	"strings"
+
+	"code.gitea.io/gitea/modules/charset"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/translation"
+	"golang.org/x/net/html"
+	"golang.org/x/net/html/atom"
+)
+
+var (
+	// filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2"
+	filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`)
+)
+
+type FilePreview struct {
+	fileContent []template.HTML
+	subTitle    template.HTML
+	lineOffset  int
+	urlFull     string
+	filePath    string
+	start       int
+	end         int
+}
+
+func NewFilePreview(ctx *RenderContext, node *html.Node, locale translation.Locale) *FilePreview {
+	preview := &FilePreview{}
+
+	m := filePreviewPattern.FindStringSubmatchIndex(node.Data)
+	if m == nil {
+		return nil
+	}
+
+	// Ensure that every group has a match
+	if slices.Contains(m, -1) {
+		return nil
+	}
+
+	preview.urlFull = node.Data[m[0]:m[1]]
+
+	// Ensure that we only use links to local repositories
+	if !strings.HasPrefix(preview.urlFull, setting.AppURL+setting.AppSubURL) {
+		return nil
+	}
+
+	projPath := strings.TrimSuffix(node.Data[m[2]:m[3]], "/")
+
+	commitSha := node.Data[m[4]:m[5]]
+	preview.filePath = node.Data[m[6]:m[7]]
+	hash := node.Data[m[8]:m[9]]
+
+	preview.start = m[0]
+	preview.end = m[1]
+
+	// If url ends in '.', it's very likely that it is not part of the
+	// actual url but used to finish a sentence.
+	if strings.HasSuffix(preview.urlFull, ".") {
+		preview.end--
+		preview.urlFull = preview.urlFull[:len(preview.urlFull)-1]
+		hash = hash[:len(hash)-1]
+	}
+
+	projPathSegments := strings.Split(projPath, "/")
+	fileContent, err := DefaultProcessorHelper.GetRepoFileContent(
+		ctx.Ctx,
+		projPathSegments[len(projPathSegments)-2],
+		projPathSegments[len(projPathSegments)-1],
+		commitSha, preview.filePath,
+	)
+	if err != nil {
+		return nil
+	}
+
+	lineSpecs := strings.Split(hash, "-")
+	lineCount := len(fileContent)
+
+	commitLinkBuffer := new(bytes.Buffer)
+	html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitSha[0:7], "text black"))
+
+	if len(lineSpecs) == 1 {
+		line, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
+		if line < 1 || line > lineCount {
+			return nil
+		}
+
+		preview.fileContent = fileContent[line-1 : line]
+		preview.subTitle = locale.Tr(
+			"markup.filepreview.line", line,
+			template.HTML(commitLinkBuffer.String()),
+		)
+
+		preview.lineOffset = line - 1
+	} else {
+		startLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
+		endLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L"))
+
+		if startLine < 1 || endLine < 1 || startLine > lineCount || endLine > lineCount || endLine < startLine {
+			return nil
+		}
+
+		preview.fileContent = fileContent[startLine-1 : endLine]
+		preview.subTitle = locale.Tr(
+			"markup.filepreview.lines", startLine, endLine,
+			template.HTML(commitLinkBuffer.String()),
+		)
+
+		preview.lineOffset = startLine - 1
+	}
+
+	return preview
+}
+
+func (p *FilePreview) CreateHtml(locale translation.Locale) *html.Node {
+	table := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.Table.String(),
+		Attr: []html.Attribute{{Key: "class", Val: "file-preview"}},
+	}
+	tbody := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.Tbody.String(),
+	}
+
+	status := &charset.EscapeStatus{}
+	statuses := make([]*charset.EscapeStatus, len(p.fileContent))
+	for i, line := range p.fileContent {
+		statuses[i], p.fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext)
+		status = status.Or(statuses[i])
+	}
+
+	for idx, code := range p.fileContent {
+		tr := &html.Node{
+			Type: html.ElementNode,
+			Data: atom.Tr.String(),
+		}
+
+		lineNum := strconv.Itoa(p.lineOffset + idx + 1)
+
+		tdLinesnum := &html.Node{
+			Type: html.ElementNode,
+			Data: atom.Td.String(),
+			Attr: []html.Attribute{
+				{Key: "id", Val: "L" + lineNum},
+				{Key: "class", Val: "lines-num"},
+			},
+		}
+		spanLinesNum := &html.Node{
+			Type: html.ElementNode,
+			Data: atom.Span.String(),
+			Attr: []html.Attribute{
+				{Key: "id", Val: "L" + lineNum},
+				{Key: "data-line-number", Val: lineNum},
+			},
+		}
+		tdLinesnum.AppendChild(spanLinesNum)
+		tr.AppendChild(tdLinesnum)
+
+		if status.Escaped {
+			tdLinesEscape := &html.Node{
+				Type: html.ElementNode,
+				Data: atom.Td.String(),
+				Attr: []html.Attribute{
+					{Key: "class", Val: "lines-escape"},
+				},
+			}
+
+			if statuses[idx].Escaped {
+				btnTitle := ""
+				if statuses[idx].HasInvisible {
+					btnTitle += locale.TrString("repo.invisible_runes_line") + " "
+				}
+				if statuses[idx].HasAmbiguous {
+					btnTitle += locale.TrString("repo.ambiguous_runes_line")
+				}
+
+				escapeBtn := &html.Node{
+					Type: html.ElementNode,
+					Data: atom.Button.String(),
+					Attr: []html.Attribute{
+						{Key: "class", Val: "toggle-escape-button btn interact-bg"},
+						{Key: "title", Val: btnTitle},
+					},
+				}
+				tdLinesEscape.AppendChild(escapeBtn)
+			}
+
+			tr.AppendChild(tdLinesEscape)
+		}
+
+		tdCode := &html.Node{
+			Type: html.ElementNode,
+			Data: atom.Td.String(),
+			Attr: []html.Attribute{
+				{Key: "rel", Val: "L" + lineNum},
+				{Key: "class", Val: "lines-code chroma"},
+			},
+		}
+		codeInner := &html.Node{
+			Type: html.ElementNode,
+			Data: atom.Code.String(),
+			Attr: []html.Attribute{{Key: "class", Val: "code-inner"}},
+		}
+		codeText := &html.Node{
+			Type: html.RawNode,
+			Data: string(code),
+		}
+		codeInner.AppendChild(codeText)
+		tdCode.AppendChild(codeInner)
+		tr.AppendChild(tdCode)
+
+		tbody.AppendChild(tr)
+	}
+
+	table.AppendChild(tbody)
+
+	twrapper := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.Div.String(),
+		Attr: []html.Attribute{{Key: "class", Val: "ui table"}},
+	}
+	twrapper.AppendChild(table)
+
+	header := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.Div.String(),
+		Attr: []html.Attribute{{Key: "class", Val: "header"}},
+	}
+	afilepath := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.A.String(),
+		Attr: []html.Attribute{
+			{Key: "href", Val: p.urlFull},
+			{Key: "class", Val: "muted"},
+		},
+	}
+	afilepath.AppendChild(&html.Node{
+		Type: html.TextNode,
+		Data: p.filePath,
+	})
+	header.AppendChild(afilepath)
+
+	psubtitle := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.Span.String(),
+		Attr: []html.Attribute{{Key: "class", Val: "text small grey"}},
+	}
+	psubtitle.AppendChild(&html.Node{
+		Type: html.RawNode,
+		Data: string(p.subTitle),
+	})
+	header.AppendChild(psubtitle)
+
+	preview_node := &html.Node{
+		Type: html.ElementNode,
+		Data: atom.Div.String(),
+		Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}},
+	}
+	preview_node.AppendChild(header)
+	preview_node.AppendChild(twrapper)
+
+	return preview_node
+}
diff --git a/modules/markup/html.go b/modules/markup/html.go
index 1e83dad70..2e38c05f5 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -5,19 +5,15 @@ package markup
 
 import (
 	"bytes"
-	"html/template"
 	"io"
 	"net/url"
 	"path"
 	"path/filepath"
 	"regexp"
-	"slices"
-	"strconv"
 	"strings"
 	"sync"
 
 	"code.gitea.io/gitea/modules/base"
-	"code.gitea.io/gitea/modules/charset"
 	"code.gitea.io/gitea/modules/emoji"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
@@ -65,9 +61,6 @@ var (
 
 	validLinksPattern = regexp.MustCompile(`^[a-z][\w-]+://`)
 
-	// filePreviewPattern matches "http://domain/org/repo/src/commit/COMMIT/filepath#L1-L2"
-	filePreviewPattern = regexp.MustCompile(`https?://((?:\S+/){3})src/commit/([0-9a-f]{4,64})/(\S+)#(L\d+(?:-L\d+)?)`)
-
 	// While this email regex is definitely not perfect and I'm sure you can come up
 	// with edge cases, it is still accepted by the CommonMark specification, as
 	// well as the HTML5 spec:
@@ -1072,252 +1065,28 @@ func filePreviewPatternProcessor(ctx *RenderContext, node *html.Node) {
 
 	next := node.NextSibling
 	for node != nil && node != next {
-		m := filePreviewPattern.FindStringSubmatchIndex(node.Data)
-		if m == nil {
-			return
-		}
-
-		// Ensure that every group has a match
-		if slices.Contains(m, -1) {
-			return
-		}
-
-		urlFull := node.Data[m[0]:m[1]]
-
-		// Ensure that we only use links to local repositories
-		if !strings.HasPrefix(urlFull, setting.AppURL+setting.AppSubURL) {
-			return
-		}
-
-		projPath := strings.TrimSuffix(node.Data[m[2]:m[3]], "/")
-
-		commitSha := node.Data[m[4]:m[5]]
-		filePath := node.Data[m[6]:m[7]]
-		hash := node.Data[m[8]:m[9]]
-
-		start := m[0]
-		end := m[1]
-
-		// If url ends in '.', it's very likely that it is not part of the
-		// actual url but used to finish a sentence.
-		if strings.HasSuffix(urlFull, ".") {
-			end--
-			urlFull = urlFull[:len(urlFull)-1]
-			hash = hash[:len(hash)-1]
-		}
-
-		projPathSegments := strings.Split(projPath, "/")
-		fileContent, err := DefaultProcessorHelper.GetRepoFileContent(
-			ctx.Ctx,
-			projPathSegments[len(projPathSegments)-2],
-			projPathSegments[len(projPathSegments)-1],
-			commitSha, filePath,
-		)
-		if err != nil {
-			return
-		}
-
-		lineSpecs := strings.Split(hash, "-")
-		lineCount := len(fileContent)
-
-		commitLinkBuffer := new(bytes.Buffer)
-		html.Render(commitLinkBuffer, createLink(node.Data[m[0]:m[5]], commitSha[0:7], "text black"))
-
-		var subTitle template.HTML
-		var lineOffset int
-
 		locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale)
 		if !ok {
 			locale = translation.NewLocale("en-US")
 		}
 
-		if len(lineSpecs) == 1 {
-			line, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
-			if line < 1 || line > lineCount {
-				return
-			}
-
-			fileContent = fileContent[line-1 : line]
-			subTitle = locale.Tr(
-				"markup.filepreview.line", line,
-				template.HTML(commitLinkBuffer.String()),
-			)
-
-			lineOffset = line - 1
-		} else {
-			startLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[0], "L"))
-			endLine, _ := strconv.Atoi(strings.TrimPrefix(lineSpecs[1], "L"))
-
-			if startLine < 1 || endLine < 1 || startLine > lineCount || endLine > lineCount || endLine < startLine {
-				return
-			}
-
-			fileContent = fileContent[startLine-1 : endLine]
-			subTitle = locale.Tr(
-				"markup.filepreview.lines", startLine, endLine,
-				template.HTML(commitLinkBuffer.String()),
-			)
-
-			lineOffset = startLine - 1
+		preview := NewFilePreview(ctx, node, locale)
+		if preview == nil {
+			return
 		}
 
-		table := &html.Node{
-			Type: html.ElementNode,
-			Data: atom.Table.String(),
-			Attr: []html.Attribute{{Key: "class", Val: "file-preview"}},
-		}
-		tbody := &html.Node{
-			Type: html.ElementNode,
-			Data: atom.Tbody.String(),
-		}
-
-		status := &charset.EscapeStatus{}
-		statuses := make([]*charset.EscapeStatus, len(fileContent))
-		for i, line := range fileContent {
-			statuses[i], fileContent[i] = charset.EscapeControlHTML(line, locale, charset.FileviewContext)
-			status = status.Or(statuses[i])
-		}
-
-		for idx, code := range fileContent {
-			tr := &html.Node{
-				Type: html.ElementNode,
-				Data: atom.Tr.String(),
-			}
-
-			lineNum := strconv.Itoa(lineOffset + idx + 1)
-
-			tdLinesnum := &html.Node{
-				Type: html.ElementNode,
-				Data: atom.Td.String(),
-				Attr: []html.Attribute{
-					{Key: "id", Val: "L" + lineNum},
-					{Key: "class", Val: "lines-num"},
-				},
-			}
-			spanLinesNum := &html.Node{
-				Type: html.ElementNode,
-				Data: atom.Span.String(),
-				Attr: []html.Attribute{
-					{Key: "id", Val: "L" + lineNum},
-					{Key: "data-line-number", Val: lineNum},
-				},
-			}
-			tdLinesnum.AppendChild(spanLinesNum)
-			tr.AppendChild(tdLinesnum)
-
-			if status.Escaped {
-				tdLinesEscape := &html.Node{
-					Type: html.ElementNode,
-					Data: atom.Td.String(),
-					Attr: []html.Attribute{
-						{Key: "class", Val: "lines-escape"},
-					},
-				}
-
-				if statuses[idx].Escaped {
-					btnTitle := ""
-					if statuses[idx].HasInvisible {
-						btnTitle += locale.TrString("repo.invisible_runes_line") + " "
-					}
-					if statuses[idx].HasAmbiguous {
-						btnTitle += locale.TrString("repo.ambiguous_runes_line")
-					}
-
-					escapeBtn := &html.Node{
-						Type: html.ElementNode,
-						Data: atom.Button.String(),
-						Attr: []html.Attribute{
-							{Key: "class", Val: "toggle-escape-button btn interact-bg"},
-							{Key: "title", Val: btnTitle},
-						},
-					}
-					tdLinesEscape.AppendChild(escapeBtn)
-				}
-
-				tr.AppendChild(tdLinesEscape)
-			}
-
-			tdCode := &html.Node{
-				Type: html.ElementNode,
-				Data: atom.Td.String(),
-				Attr: []html.Attribute{
-					{Key: "rel", Val: "L" + lineNum},
-					{Key: "class", Val: "lines-code chroma"},
-				},
-			}
-			codeInner := &html.Node{
-				Type: html.ElementNode,
-				Data: atom.Code.String(),
-				Attr: []html.Attribute{{Key: "class", Val: "code-inner"}},
-			}
-			codeText := &html.Node{
-				Type: html.RawNode,
-				Data: string(code),
-			}
-			codeInner.AppendChild(codeText)
-			tdCode.AppendChild(codeInner)
-			tr.AppendChild(tdCode)
-
-			tbody.AppendChild(tr)
-		}
-
-		table.AppendChild(tbody)
-
-		twrapper := &html.Node{
-			Type: html.ElementNode,
-			Data: atom.Div.String(),
-			Attr: []html.Attribute{{Key: "class", Val: "ui table"}},
-		}
-		twrapper.AppendChild(table)
-
-		header := &html.Node{
-			Type: html.ElementNode,
-			Data: atom.Div.String(),
-			Attr: []html.Attribute{{Key: "class", Val: "header"}},
-		}
-		afilepath := &html.Node{
-			Type: html.ElementNode,
-			Data: atom.A.String(),
-			Attr: []html.Attribute{
-				{Key: "href", Val: urlFull},
-				{Key: "class", Val: "muted"},
-			},
-		}
-		afilepath.AppendChild(&html.Node{
-			Type: html.TextNode,
-			Data: filePath,
-		})
-		header.AppendChild(afilepath)
-
-		psubtitle := &html.Node{
-			Type: html.ElementNode,
-			Data: atom.Span.String(),
-			Attr: []html.Attribute{{Key: "class", Val: "text small grey"}},
-		}
-		psubtitle.AppendChild(&html.Node{
-			Type: html.RawNode,
-			Data: string(subTitle),
-		})
-		header.AppendChild(psubtitle)
-
-		preview := &html.Node{
-			Type: html.ElementNode,
-			Data: atom.Div.String(),
-			Attr: []html.Attribute{{Key: "class", Val: "file-preview-box"}},
-		}
-		preview.AppendChild(header)
-		preview.AppendChild(twrapper)
+		preview_node := preview.CreateHtml(locale)
 
 		// Specialized version of replaceContent, so the parent paragraph element is not destroyed from our div
-		before := node.Data[:start]
-		after := node.Data[end:]
+		before := node.Data[:preview.start]
+		after := node.Data[preview.end:]
 		node.Data = before
 		nextSibling := node.NextSibling
 		node.Parent.InsertBefore(&html.Node{
 			Type: html.RawNode,
 			Data: "</p>",
 		}, nextSibling)
-		node.Parent.InsertBefore(preview, nextSibling)
+		node.Parent.InsertBefore(preview_node, nextSibling)
 		node.Parent.InsertBefore(&html.Node{
 			Type: html.RawNode,
 			Data: "<p>" + after,