diff --git a/forgejo/README.md b/forgejo/README.md new file mode 100644 index 0000000..fab80e1 --- /dev/null +++ b/forgejo/README.md @@ -0,0 +1,145 @@ +# Forgejo integration + +This directory contains the Forgejo source patch and custom assets for the +ifcurl preview extension. The integration adds: + +- **Markdown preview** — ifc:// URLs in markdown render as inline `
` + preview images fetched from the ifcurl preview service. +- **"View in 3D" button** — appears on `.ifc` file view pages alongside Raw / + Permalink / History. +- **Browser viewer** — a self-contained WebGL IFC viewer served as a Forgejo + custom asset. + +--- + +## Directory layout + +``` +forgejo/ + go.patch ← diff against Forgejo source (apply once) + modules/markup/markdown/ + ifc_url.go ← new Go source file (copy into source tree) + ifc_url_test.go ← Go tests (copy into source tree) + custom/public/assets/ + viewer.html ← browser IFC viewer (no rebuild needed) + viewer-url.js ← viewer URL logic module + templates/custom/ + footer.tmpl ← "View in 3D" button injection +``` + +--- + +## Prerequisites + +- Forgejo source tree cloned and building cleanly +- `go` 1.21+ in PATH +- ifcurl preview service running and reachable from the Forgejo server + +The patch was written against the `v13.0` branch of Forgejo. Check which +Forgejo commit the patch was authored against with: + +```bash +cd /path/to/forgejo +git log --oneline modules/markup/markdown/markdown.go | head -3 +``` + +--- + +## Applying the patch + +### 1. Copy new Go source files + +```bash +cp forgejo/modules/markup/markdown/ifc_url.go /path/to/forgejo/modules/markup/markdown/ +cp forgejo/modules/markup/markdown/ifc_url_test.go /path/to/forgejo/modules/markup/markdown/ +``` + +### 2. Apply the diff to existing files + +```bash +cd /path/to/forgejo +git apply /path/to/ifcurl/forgejo/go.patch +``` + +This adds one line to `modules/markup/markdown/markdown.go` and six lines to +`modules/setting/markup.go`. If the patch does not apply cleanly (e.g. after +a Forgejo upstream upgrade), the changes are small enough to apply by hand — +see `go.patch` for the exact hunks. + +### 3. Run the Go tests + +```bash +cd /path/to/forgejo +go test ./modules/markup/markdown/ -run TestIfcURL -v +``` + +All seven tests should pass before proceeding. + +### 4. Build and deploy Forgejo + +```bash +cd /path/to/forgejo +go build \ + -tags 'sqlite sqlite_unlock_notify' \ + -ldflags "-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'" \ + -o forgejo . + +sudo cp forgejo /usr/bin/forgejo +sudo systemctl restart forgejo +``` + +Adjust build tags and `-ldflags` to match your existing Forgejo build +configuration. + +--- + +## Deploying custom assets (no rebuild required) + +These files can be updated at any time without recompiling Forgejo. + +### Viewer and URL logic + +```bash +sudo cp forgejo/custom/public/assets/viewer.html /etc/forgejo/public/assets/ +sudo cp forgejo/custom/public/assets/viewer-url.js /etc/forgejo/public/assets/ +``` + +Served at `/assets/viewer.html` and `/assets/viewer-url.js`. + +### "View in 3D" footer template + +```bash +sudo mkdir -p /var/lib/forgejo/custom/templates/custom/ +sudo cp forgejo/templates/custom/footer.tmpl /var/lib/forgejo/custom/templates/custom/ +sudo systemctl restart forgejo +``` + +--- + +## Configuration + +Add to `/etc/forgejo/conf/app.ini`: + +```ini +[ifcurl] +PREVIEW_SERVICE_URL = http://localhost:8000 +``` + +Set `PREVIEW_SERVICE_URL` to the base URL of the running ifcurl preview +service. If left empty, ifc:// links in markdown render as plain links with +no preview image. + +--- + +## Upgrading Forgejo + +After upgrading Forgejo upstream: + +1. Re-apply `go.patch` (or apply the two hunks by hand if context has shifted). +2. Copy `ifc_url.go` and `ifc_url_test.go` back in — they are not modified by + upstream. +3. Run the Go tests to verify compatibility. +4. Rebuild and redeploy. + +Custom assets and `footer.tmpl` do not require a rebuild and are unaffected by +Forgejo upgrades. diff --git a/forgejo/go.patch b/forgejo/go.patch new file mode 100644 index 0000000..b3f30eb --- /dev/null +++ b/forgejo/go.patch @@ -0,0 +1,31 @@ +diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go +index 2b19e0f1c9..12be3e1c24 100644 +--- a/modules/markup/markdown/markdown.go ++++ b/modules/markup/markdown/markdown.go +@@ -116,6 +116,7 @@ func SpecializedMarkdown() goldmark.Markdown { + math.NewExtension( + math.Enabled(setting.Markdown.EnableMath), + ), ++ NewIfcURLExtension(), + ), + goldmark.WithParserOptions( + parser.WithAttribute(), +diff --git a/modules/setting/markup.go b/modules/setting/markup.go +index 4ab9e7b2d1..c1fad254b4 100644 +--- a/modules/setting/markup.go ++++ b/modules/setting/markup.go +@@ -59,8 +59,14 @@ type MarkupSanitizerRule struct { + AllowDataURIImages bool + } + ++// IfcURL holds configuration for the ifcurl preview service integration. ++var IfcURL = struct { ++ PreviewServiceURL string `ini:"PREVIEW_SERVICE_URL"` ++}{} ++ + func loadMarkupFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "markdown", &Markdown) ++ mustMapSetting(rootCfg, "ifcurl", &IfcURL) + + MermaidMaxSourceCharacters = rootCfg.Section("markup").Key("MERMAID_MAX_SOURCE_CHARACTERS").MustInt(50000) + FilePreviewMaxLines = rootCfg.Section("markup").Key("FILEPREVIEW_MAX_LINES").MustInt(50) diff --git a/forgejo/modules/markup/markdown/ifc_url.go b/forgejo/modules/markup/markdown/ifc_url.go new file mode 100644 index 0000000..40eda14 --- /dev/null +++ b/forgejo/modules/markup/markdown/ifc_url.go @@ -0,0 +1,269 @@ +// 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 +} + +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) + 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, + ) + 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 +} + +func NewIfcURLExtension() goldmark.Extender { + return &ifcURLExtension{ + previewServiceURL: setting.IfcURL.PreviewServiceURL, + } +} + +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}, 999), + ), + ) +}