diff --git a/forgejo/custom/public/assets/ifcurl.js b/forgejo/custom/public/assets/ifcurl.js index b8fdb25..de37f23 100644 --- a/forgejo/custom/public/assets/ifcurl.js +++ b/forgejo/custom/public/assets/ifcurl.js @@ -24,10 +24,45 @@ function makeViewerLink(info, label, cls) { return a; } +// Walk text nodes in rendered markdown and wrap bare ifc:// URLs in anchors so +// that replaceIfcAnchors() can promote them to preview figures. +function linkifyBareIfcUrls() { + const IFC_URL = /ifc:\/\/[^\s<>"']+/g; + const TRAILING = /[.,;:!?)]+$/; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const nodes = []; + let n; + while ((n = walker.nextNode())) { + if (n.parentElement.closest('a, code, pre, .ifcurl-preview')) continue; + if (IFC_URL.test(n.textContent)) nodes.push(n); + IFC_URL.lastIndex = 0; + } + nodes.forEach(function(textNode) { + const text = textNode.textContent; + const parts = text.split(IFC_URL); + const matches = text.match(IFC_URL); + if (!matches) return; + const frag = document.createDocumentFragment(); + parts.forEach(function(part, i) { + if (part) frag.appendChild(document.createTextNode(part)); + if (matches[i]) { + let url = matches[i]; + const tail = TRAILING.exec(url); + if (tail) url = url.slice(0, tail.index); + const a = document.createElement('a'); + a.href = url; + a.textContent = url; + frag.appendChild(a); + if (tail) frag.appendChild(document.createTextNode(tail[0])); + } + }); + textNode.parentNode.replaceChild(frag, textNode); + }); +} + // Replace links in rendered markdown with preview figures. -// Handles [title](ifc://...) links produced by Goldmark's standard link parser — -// no Go extension required. Skips anchors already inside .ifcurl-preview (those -// were rendered server-side by ifc_url.go when the patched binary is in use). +// Handles [title](ifc://...) links produced by Goldmark's standard link parser +// and bare ifc:// URLs promoted by linkifyBareIfcUrls(). function replaceIfcAnchors() { const origin = window.location.origin; document.querySelectorAll('a[href^="ifc://"]').forEach(function(a) { @@ -62,6 +97,7 @@ function replaceIfcAnchors() { function init() { const host = window.location.host; + linkifyBareIfcUrls(); replaceIfcAnchors(); // ----------------------------------------------------------------------- diff --git a/forgejo/modules/markup/markdown/ifc_url.go b/forgejo/modules/markup/markdown/ifc_url.go deleted file mode 100644 index ae06c0d..0000000 --- a/forgejo/modules/markup/markdown/ifc_url.go +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright 2026 The Forgejo Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -// ifc_url.go adds preview rendering for ifc:// URLs in Markdown. -// -// Configuration (app.ini): -// -// [ifcurl] -// PREVIEW_SERVICE_URL = http://localhost:8000 -// -// Integration in markdown.go — add to goldmark.WithExtensions(...): -// -// NewIfcURLExtension(), - -package markdown - -import ( - "bytes" - "fmt" - "net/url" - "strings" - - "forgejo.org/modules/setting" - - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/text" - "github.com/yuin/goldmark/util" -) - -var ifcURLKind = ast.NewNodeKind("IfcURL") - -type ifcURLNode struct { - ast.BaseInline - rawURL string -} - -func (n *ifcURLNode) Kind() ast.NodeKind { return ifcURLKind } -func (n *ifcURLNode) Dump(src []byte, level int) { - ast.DumpHelper(n, src, level, map[string]string{"URL": n.rawURL}, nil) -} - -// ifcURLTransformer walks the parsed AST and replaces ifc:// occurrences with -// ifcURLNode. It handles both [title](ifc://...) links and bare ifc:// URLs. -// -// Bare URLs may span multiple consecutive text-like siblings because goldmark's -// emphasis parser splits text nodes at delimiter characters (e.g. the '_' in -// "?path=_test.ifc"), so we collect siblings until a URL terminator is found. -type ifcURLTransformer struct{} - -// urlTerminators end a bare ifc:// URL in inline text. -const urlTerminators = " \t\n\r<>\"')" - -func (t *ifcURLTransformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { - if setting.IfcURL.PreviewServiceURL == "" { - return - } - src := reader.Source() - - // Replace [title](ifc://...) link nodes. - var links []*ast.Link - if err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkContinue, nil - } - if link, ok := n.(*ast.Link); ok && strings.HasPrefix(string(link.Destination), "ifc://") { - links = append(links, link) - } - return ast.WalkContinue, nil - }); err != nil { - return - } - for _, link := range links { - link.Parent().ReplaceChild(link.Parent(), link, &ifcURLNode{rawURL: string(link.Destination)}) - } - - // Replace bare ifc:// URLs in inline text nodes. - type span struct { - parent ast.Node - first ast.Node - spanned []ast.Node // siblings consumed beyond first (last element is the final node) - rawURL string - before []byte // text in first node before "ifc://" - trailing []byte // text in last node after the URL end - } - - var spans []span - claimed := map[ast.Node]bool{} - - if err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering || claimed[n] { - return ast.WalkContinue, nil - } - tb, ok := inlineText(n, src) - if !ok { - return ast.WalkContinue, nil - } - idx := bytes.Index(tb, []byte("ifc://")) - if idx < 0 { - return ast.WalkContinue, nil - } - - before := tb[:idx] - urlBytes := append([]byte(nil), tb[idx:]...) - var spanned []ast.Node - var trailing []byte - - if end := bytes.IndexAny(urlBytes, urlTerminators); end >= 0 { - trailing = append([]byte(nil), urlBytes[end:]...) - urlBytes = urlBytes[:end] - } else { - for sib := n.NextSibling(); sib != nil; sib = sib.NextSibling() { - sibTB, ok := inlineText(sib, src) - if !ok { - break - } - spanned = append(spanned, sib) - if end := bytes.IndexAny(sibTB, urlTerminators); end >= 0 { - urlBytes = append(urlBytes, sibTB[:end]...) - trailing = append([]byte(nil), sibTB[end:]...) - break - } - urlBytes = append(urlBytes, sibTB...) - } - } - - if len(urlBytes) <= len("ifc://") { - return ast.WalkContinue, nil - } - - spans = append(spans, span{ - parent: n.Parent(), - first: n, - spanned: spanned, - rawURL: string(urlBytes), - before: append([]byte(nil), before...), - trailing: trailing, - }) - claimed[n] = true - for _, s := range spanned { - claimed[s] = true - } - return ast.WalkContinue, nil - }); err != nil { - return - } - - for _, s := range spans { - parent := s.parent - if parent == nil { - continue - } - last := s.first - if len(s.spanned) > 0 { - last = s.spanned[len(s.spanned)-1] - } - - // Remove intermediate spanned nodes; replace or remove the last one. - for _, m := range s.spanned { - if m == last { - continue - } - parent.RemoveChild(parent, m) - } - if last != s.first { - if len(s.trailing) > 0 { - parent.ReplaceChild(parent, last, ast.NewString(s.trailing)) - } else { - parent.RemoveChild(parent, last) - } - } - - // Build replacement nodes for first: [before?] ifcURLNode [trailing if single-node?] - ifcNode := &ifcURLNode{rawURL: s.rawURL} - seq := make([]ast.Node, 0, 3) - if len(s.before) > 0 { - seq = append(seq, ast.NewString(s.before)) - } - seq = append(seq, ifcNode) - if last == s.first && len(s.trailing) > 0 { - seq = append(seq, ast.NewString(s.trailing)) - } - - parent.ReplaceChild(parent, s.first, seq[0]) - prev := seq[0] - for _, nn := range seq[1:] { - parent.InsertAfter(parent, prev, nn) - prev = nn - } - } -} - -// inlineText returns the raw bytes of an ast.Text or ast.String node. -func inlineText(n ast.Node, src []byte) ([]byte, bool) { - switch s := n.(type) { - case *ast.Text: - seg := s.Segment - return src[seg.Start:seg.Stop], true - case *ast.String: - return s.Value, true - } - return nil, false -} - -type ifcURLRenderer struct { - previewServiceURL string - serviceToken string -} - -func (r *ifcURLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(ifcURLKind, r.render) -} - -func (r *ifcURLRenderer) render(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkSkipChildren, nil - } - n := node.(*ifcURLNode) - - previewURL := r.previewServiceURL + "/preview?url=" + url.QueryEscape(n.rawURL) - if r.serviceToken != "" { - previewURL += "&token=" + url.QueryEscape(r.serviceToken) - } - viewerURL := "/assets/viewer.html?url=" + url.QueryEscape(n.rawURL) - safeIfc := htmlEscapeString(n.rawURL) - safePreview := htmlEscapeString(previewURL) - safeViewer := htmlEscapeString(viewerURL) - - fmt.Fprintf(w, - `
`+ - ``+ - `IFC preview`+ - ``+ - `
%s
`+ - `
`, - safeViewer, safeIfc, safePreview, safeIfc, safeIfc, - ) - return ast.WalkSkipChildren, nil -} - -func htmlEscapeString(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - s = strings.ReplaceAll(s, `"`, """) - s = strings.ReplaceAll(s, "'", "'") - return s -} - -type ifcURLExtension struct { - previewServiceURL string - serviceToken string -} - -func NewIfcURLExtension() goldmark.Extender { - return &ifcURLExtension{ - previewServiceURL: setting.IfcURL.PreviewServiceURL, - serviceToken: setting.IfcURL.ServiceToken, - } -} - -func (e *ifcURLExtension) Extend(m goldmark.Markdown) { - m.Parser().AddOptions( - parser.WithASTTransformers( - util.Prioritized(&ifcURLTransformer{}, 999), - ), - ) - m.Renderer().AddOptions( - renderer.WithNodeRenderers( - util.Prioritized(&ifcURLRenderer{ - previewServiceURL: e.previewServiceURL, - serviceToken: e.serviceToken, - }, 999), - ), - ) -} diff --git a/forgejo/modules/markup/markdown/ifc_url_test.go b/forgejo/modules/markup/markdown/ifc_url_test.go deleted file mode 100644 index 0751ef9..0000000 --- a/forgejo/modules/markup/markdown/ifc_url_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2026 The Forgejo Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markdown_test - -import ( - "bytes" - "testing" - - "forgejo.org/modules/markup/markdown" - "forgejo.org/modules/setting" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/yuin/goldmark" -) - -func newTestMd(t *testing.T) goldmark.Markdown { - t.Helper() - setting.IfcURL.PreviewServiceURL = "http://localhost:8000" - t.Cleanup(func() { - setting.IfcURL.PreviewServiceURL = "" - setting.IfcURL.ServiceToken = "" - }) - return goldmark.New(goldmark.WithExtensions(markdown.NewIfcURLExtension())) -} - -func convert(t *testing.T, md goldmark.Markdown, src string) string { - t.Helper() - var buf bytes.Buffer - require.NoError(t, md.Convert([]byte(src), &buf)) - return buf.String() -} - -func TestIfcURL_LinkForm(t *testing.T) { - md := newTestMd(t) - out := convert(t, md, "[label](ifc://example.com/org/repo@heads/main?path=model.ifc)") - assert.Contains(t, out, `
`) - assert.Contains(t, out, `ifc://example.com/org/repo@heads/main?path=model.ifc`) - assert.Contains(t, out, ``) - assert.Contains(t, out, `ifc://example.com/org/repo@heads/main?path=model.ifc`) -} - -func TestIfcURL_BareURLWithUnderscoreInPath(t *testing.T) { - // goldmark splits text at _ (emphasis delimiter); transformer must reassemble siblings - md := newTestMd(t) - out := convert(t, md, "ifc://example.com/org/repo@HEAD?path=_test_model.ifc") - assert.Contains(t, out, `
`) - assert.Contains(t, out, `_test_model.ifc`) -} - -func TestIfcURL_DisabledWhenNoServiceURL(t *testing.T) { - setting.IfcURL.PreviewServiceURL = "" - md := goldmark.New(goldmark.WithExtensions(markdown.NewIfcURLExtension())) - out := convert(t, md, "[label](ifc://example.com/org/repo@HEAD?path=model.ifc)") - assert.NotContains(t, out, `
`) -} - -func TestIfcURL_HTMLEscapesURLInOutput(t *testing.T) { - md := newTestMd(t) - // angle brackets and quotes in the URL must be escaped in HTML attributes - out := convert(t, md, `[label](ifc://example.com/org/repo@HEAD?path=m.ifc&x=<"'>)`) - assert.NotContains(t, out, `<"'>`) - assert.Contains(t, out, `<`) - assert.Contains(t, out, `"`) -} - -func TestIfcURL_NonIfcLinkUnchanged(t *testing.T) { - md := newTestMd(t) - out := convert(t, md, "[label](https://example.com/foo)") - assert.NotContains(t, out, ``) - assert.Contains(t, out, "for details.") -} - -func TestIfcURL_BareURLWithAtInQueryValue(t *testing.T) { - // @ in a query-string value must not truncate the URL; verify the full - // selector value appears in the figcaption (raw URL, HTML-escaped). - md := newTestMd(t) - out := convert(t, md, "ifc://example.com/org/repo@HEAD?path=m.ifc&selector=abc@def") - assert.Contains(t, out, `
`) - assert.Contains(t, out, `selector=abc@def`) -} - -func TestIfcURL_BareURLTerminatesAtClosingParen(t *testing.T) { - md := newTestMd(t) - out := convert(t, md, "(ifc://example.com/org/repo@HEAD?path=m.ifc) more text") - assert.Contains(t, out, `
`) - // The closing ) and trailing text must appear outside the figure - assert.Contains(t, out, ") more text") - // The URL in the preview must not contain the closing paren - assert.NotContains(t, out, "m.ifc)") -} - -func TestIfcURL_BareURLFollowedByPeriod(t *testing.T) { - md := newTestMd(t) - out := convert(t, md, "See ifc://example.com/org/repo@HEAD?path=m.ifc. Details follow.") - assert.Contains(t, out, `
`) - // Period and trailing text must remain in the output - assert.Contains(t, out, "Details follow.") -} - -func TestIfcURL_ServiceTokenInPreviewURL(t *testing.T) { - setting.IfcURL.PreviewServiceURL = "http://localhost:8000" - setting.IfcURL.ServiceToken = "mytoken123" - t.Cleanup(func() { - setting.IfcURL.PreviewServiceURL = "" - setting.IfcURL.ServiceToken = "" - }) - md := goldmark.New(goldmark.WithExtensions(markdown.NewIfcURLExtension())) - out := convert(t, md, "[label](ifc://example.com/org/repo@HEAD?path=model.ifc)") - assert.Contains(t, out, `&token=mytoken123`) -} - -func TestIfcURL_NoTokenInPreviewURLWhenUnset(t *testing.T) { - md := newTestMd(t) - out := convert(t, md, "[label](ifc://example.com/org/repo@HEAD?path=model.ifc)") - assert.NotContains(t, out, `token=`) -}