mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
Replace Go goldmark extension with JS text-node linkification
Bare ifc:// URLs in markdown are now handled entirely client-side: linkifyBareIfcUrls() walks rendered HTML text nodes and promotes bare ifc:// strings to <a> elements, which replaceIfcAnchors() then converts to preview figures. The [title](ifc://...) form was already handled by Goldmark's standard link parser, so no Go extension is needed at all. Removes ifc_url.go and ifc_url_test.go (the goldmark AST transformer and renderer) from the repository. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
97ae246aa2
commit
4b53dfe426
3 changed files with 39 additions and 413 deletions
|
|
@ -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 <a href="ifc://..."> 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();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
`<figure class="ifcurl-preview">`+
|
||||
`<a href="%s" title="%s">`+
|
||||
`<img src="%s" alt="IFC preview" loading="lazy" style="max-width:100%%"/>`+
|
||||
`</a>`+
|
||||
`<figcaption><a href="%s"><code>%s</code></a></figcaption>`+
|
||||
`</figure>`,
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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, `<figure class="ifcurl-preview">`)
|
||||
assert.Contains(t, out, `ifc://example.com/org/repo@heads/main?path=model.ifc`)
|
||||
assert.Contains(t, out, `<img src="http://localhost:8000/preview?url=`)
|
||||
assert.Contains(t, out, `/assets/viewer.html?url=`)
|
||||
}
|
||||
|
||||
func TestIfcURL_BareURL(t *testing.T) {
|
||||
md := newTestMd(t)
|
||||
out := convert(t, md, "ifc://example.com/org/repo@heads/main?path=model.ifc")
|
||||
assert.Contains(t, out, `<figure class="ifcurl-preview">`)
|
||||
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, `<figure class="ifcurl-preview">`)
|
||||
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, `<figure class="ifcurl-preview">`)
|
||||
}
|
||||
|
||||
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, `<figure`)
|
||||
assert.Contains(t, out, `<a href="https://example.com/foo"`)
|
||||
}
|
||||
|
||||
func TestIfcURL_InlineAmongText(t *testing.T) {
|
||||
md := newTestMd(t)
|
||||
out := convert(t, md, "See ifc://example.com/org/repo@HEAD?path=m.ifc for details.")
|
||||
assert.Contains(t, out, `<figure class="ifcurl-preview">`)
|
||||
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, `<figure class="ifcurl-preview">`)
|
||||
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, `<figure class="ifcurl-preview">`)
|
||||
// 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, `<figure class="ifcurl-preview">`)
|
||||
// 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=`)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue