forked from Mirrors/gomuks
hicli/html,web/timeline: add syntax highlighting for code blocks
This commit is contained in:
parent
8cc475e66b
commit
afa6d3aa4b
8 changed files with 115 additions and 15 deletions
|
@ -28,6 +28,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/alecthomas/chroma/v2/styles"
|
||||||
"github.com/rs/zerolog/hlog"
|
"github.com/rs/zerolog/hlog"
|
||||||
"go.mau.fi/util/exerrors"
|
"go.mau.fi/util/exerrors"
|
||||||
"go.mau.fi/util/exhttp"
|
"go.mau.fi/util/exhttp"
|
||||||
|
@ -36,6 +37,7 @@ import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
|
|
||||||
|
"go.mau.fi/gomuks/pkg/hicli"
|
||||||
"go.mau.fi/gomuks/web"
|
"go.mau.fi/gomuks/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -45,6 +47,7 @@ func (gmx *Gomuks) StartServer() {
|
||||||
api.HandleFunc("POST /auth", gmx.Authenticate)
|
api.HandleFunc("POST /auth", gmx.Authenticate)
|
||||||
api.HandleFunc("POST /upload", gmx.UploadMedia)
|
api.HandleFunc("POST /upload", gmx.UploadMedia)
|
||||||
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
|
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
|
||||||
|
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
|
||||||
apiHandler := exhttp.ApplyMiddleware(
|
apiHandler := exhttp.ApplyMiddleware(
|
||||||
api,
|
api,
|
||||||
hlog.NewHandler(*gmx.Log),
|
hlog.NewHandler(*gmx.Log),
|
||||||
|
@ -226,3 +229,18 @@ func (gmx *Gomuks) AuthMiddleware(next http.Handler) http.Handler {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (gmx *Gomuks) GetCodeblockCSS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
styleName := r.PathValue("style")
|
||||||
|
if !strings.HasSuffix(styleName, ".css") {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
style := styles.Get(strings.TrimSuffix(styleName, ".css"))
|
||||||
|
if style == nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
_ = hicli.CodeBlockFormatter.WriteCSS(w, style)
|
||||||
|
}
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -5,6 +5,7 @@ go 1.23.0
|
||||||
toolchain go1.23.2
|
toolchain go1.23.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
github.com/coder/websocket v1.8.12
|
github.com/coder/websocket v1.8.12
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6
|
github.com/gabriel-vasile/mimetype v1.4.6
|
||||||
|
@ -29,6 +30,7 @@ require (
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
|
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
|
||||||
|
|
10
go.sum
10
go.sum
|
@ -2,6 +2,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||||
|
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||||
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||||
|
@ -14,9 +20,13 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
|
|
@ -17,6 +17,10 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alecthomas/chroma/v2"
|
||||||
|
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||||
|
"github.com/alecthomas/chroma/v2/lexers"
|
||||||
|
"github.com/alecthomas/chroma/v2/styles"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/atom"
|
"golang.org/x/net/html/atom"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
@ -45,7 +49,7 @@ func isSelfClosing(tag atom.Atom) bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var languageRegex = regexp.MustCompile(`^language-[a-zA-Z0-9-]+$`)
|
var languageRegex = regexp.MustCompile(`^language-([a-zA-Z0-9-]+)$`)
|
||||||
var allowedColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
var allowedColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
// This is approximately a mirror of web/src/util/mediasize.ts in gomuks
|
// This is approximately a mirror of web/src/util/mediasize.ts in gomuks
|
||||||
|
@ -489,9 +493,23 @@ func (ts *tagStack) pop(tag atom.Atom) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCodeBlockLanguage(token html.Token) string {
|
||||||
|
for _, attr := range token.Attr {
|
||||||
|
if attr.Key == "class" {
|
||||||
|
match := languageRegex.FindStringSubmatch(attr.Val)
|
||||||
|
if len(match) == 2 {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeAndLinkifyHTML(body string) (string, []id.ContentURI, error) {
|
func sanitizeAndLinkifyHTML(body string) (string, []id.ContentURI, error) {
|
||||||
tz := html.NewTokenizer(strings.NewReader(body))
|
tz := html.NewTokenizer(strings.NewReader(body))
|
||||||
var built strings.Builder
|
var built strings.Builder
|
||||||
|
var codeBlock *strings.Builder
|
||||||
|
var codeBlockLanguage string
|
||||||
var inlineImages []id.ContentURI
|
var inlineImages []id.ContentURI
|
||||||
ts := make(tagStack, 2)
|
ts := make(tagStack, 2)
|
||||||
Loop:
|
Loop:
|
||||||
|
@ -505,6 +523,13 @@ Loop:
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
case html.StartTagToken, html.SelfClosingTagToken:
|
case html.StartTagToken, html.SelfClosingTagToken:
|
||||||
token := tz.Token()
|
token := tz.Token()
|
||||||
|
if codeBlock != nil {
|
||||||
|
if token.DataAtom == atom.Code {
|
||||||
|
codeBlockLanguage = getCodeBlockLanguage(token)
|
||||||
|
}
|
||||||
|
// Don't allow any tags inside code blocks
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !tagIsAllowed(token.DataAtom) {
|
if !tagIsAllowed(token.DataAtom) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -513,6 +538,9 @@ Loop:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch token.DataAtom {
|
switch token.DataAtom {
|
||||||
|
case atom.Pre:
|
||||||
|
codeBlock = &strings.Builder{}
|
||||||
|
continue
|
||||||
case atom.A:
|
case atom.A:
|
||||||
mxc := writeA(&built, token.Attr)
|
mxc := writeA(&built, token.Attr)
|
||||||
if !mxc.IsEmpty() {
|
if !mxc.IsEmpty() {
|
||||||
|
@ -541,7 +569,11 @@ Loop:
|
||||||
case html.EndTagToken:
|
case html.EndTagToken:
|
||||||
tagName, _ := tz.TagName()
|
tagName, _ := tz.TagName()
|
||||||
tag := atom.Lookup(tagName)
|
tag := atom.Lookup(tagName)
|
||||||
if tagIsAllowed(tag) && ts.pop(tag) {
|
if tag == atom.Pre && codeBlock != nil {
|
||||||
|
writeCodeBlock(&built, codeBlockLanguage, codeBlock)
|
||||||
|
codeBlockLanguage = ""
|
||||||
|
codeBlock = nil
|
||||||
|
} else if tagIsAllowed(tag) && ts.pop(tag) {
|
||||||
if tag == atom.Font {
|
if tag == atom.Font {
|
||||||
built.WriteString("</span>")
|
built.WriteString("</span>")
|
||||||
} else {
|
} else {
|
||||||
|
@ -551,7 +583,9 @@ Loop:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case html.TextToken:
|
case html.TextToken:
|
||||||
if ts.contains(atom.Pre, atom.Code, atom.A) {
|
if codeBlock != nil {
|
||||||
|
codeBlock.Write(tz.Text())
|
||||||
|
} else if ts.contains(atom.Pre, atom.Code, atom.A) {
|
||||||
writeEscapedBytes(&built, tz.Text())
|
writeEscapedBytes(&built, tz.Text())
|
||||||
} else {
|
} else {
|
||||||
linkifyAndWriteBytes(&built, tz.Text())
|
linkifyAndWriteBytes(&built, tz.Text())
|
||||||
|
@ -568,3 +602,32 @@ Loop:
|
||||||
}
|
}
|
||||||
return built.String(), inlineImages, nil
|
return built.String(), inlineImages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var CodeBlockFormatter = chromahtml.New(
|
||||||
|
chromahtml.WithClasses(true),
|
||||||
|
chromahtml.WithLineNumbers(true),
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeCodeBlock(w *strings.Builder, language string, block *strings.Builder) {
|
||||||
|
lexer := lexers.Get(language)
|
||||||
|
if lexer == nil {
|
||||||
|
lexer = lexers.Fallback
|
||||||
|
}
|
||||||
|
lexer = chroma.Coalesce(lexer)
|
||||||
|
iter, err := lexer.Tokenise(nil, block.String())
|
||||||
|
if err != nil {
|
||||||
|
w.WriteString("<pre><code")
|
||||||
|
if language != "" {
|
||||||
|
writeAttribute(w, "class", "language-"+language)
|
||||||
|
}
|
||||||
|
w.WriteByte('>')
|
||||||
|
writeEscapedString(w, block.String())
|
||||||
|
w.WriteString("</code></pre>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = CodeBlockFormatter.Format(w, styles.Fallback, iter)
|
||||||
|
if err != nil {
|
||||||
|
// This should never fail
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -388,7 +388,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const CurrentHTMLSanitizerVersion = 2
|
const CurrentHTMLSanitizerVersion = 3
|
||||||
|
|
||||||
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
|
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
|
||||||
if evt.Type != event.EventMessage.Type || evt.LocalContent == nil || evt.LocalContent.HTMLVersion >= CurrentHTMLSanitizerVersion {
|
if evt.Type != event.EventMessage.Type || evt.LocalContent == nil || evt.LocalContent.HTMLVersion >= CurrentHTMLSanitizerVersion {
|
||||||
|
|
|
@ -18,6 +18,13 @@ import { createRoot } from "react-dom/client"
|
||||||
import App from "./App.tsx"
|
import App from "./App.tsx"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
|
const styleTags = document.createElement("style")
|
||||||
|
styleTags.textContent = `
|
||||||
|
@import "_gomuks/codeblock/github-dark.css" (prefers-color-scheme: dark);
|
||||||
|
@import "_gomuks/codeblock/github.css" (prefers-color-scheme: light);
|
||||||
|
`
|
||||||
|
document.head.appendChild(styleTags)
|
||||||
|
|
||||||
fetch("/_gomuks/auth", { method: "POST" }).then(resp => {
|
fetch("/_gomuks/auth", { method: "POST" }).then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
|
|
@ -98,7 +98,7 @@ div.timeline-event {
|
||||||
grid-template:
|
grid-template:
|
||||||
"timestamp content status" auto
|
"timestamp content status" auto
|
||||||
/ 3rem 1fr 2rem;
|
/ 3rem 1fr 2rem;
|
||||||
margin-top: 0;
|
margin-top: .25rem;
|
||||||
|
|
||||||
> div.sender-avatar, > div.event-sender-and-time {
|
> div.sender-avatar, > div.event-sender-and-time {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
Loading…
Add table
Reference in a new issue