forgejo: extract footer.tmpl inline JS into ifcurl.js asset

footer.tmpl now just loads /assets/ifcurl.js; all injection logic lives in
the new ifcurl.js file alongside the other viewer assets. Update deploy
instructions in both READMEs; fix incorrect /etc/forgejo path in top-level README.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-27 07:36:57 +01:00
parent 9c7b07e753
commit 27156a5906
5 changed files with 208 additions and 206 deletions

View file

@ -45,7 +45,7 @@
{"id":"ifcurl-i1s","title":"Test Phase 3b web viewer end-to-end","description":"After deploying the footer template (ifcurl-ach), test the full flow: open an .ifc file in Forgejo at localhost:3000, click 'View in 3D', verify the viewer loads the model. CDN versions (@thatopen/components 2.4.0, three 0.160.0, web-ifc 0.0.66) may need adjustment if the API has changed.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:01Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:04Z","started_at":"2026-04-23T19:54:56Z","closed_at":"2026-04-23T20:05:04Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-49o","type":"blocks","created_at":"2026-04-23T07:14:12Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} {"id":"ifcurl-i1s","title":"Test Phase 3b web viewer end-to-end","description":"After deploying the footer template (ifcurl-ach), test the full flow: open an .ifc file in Forgejo at localhost:3000, click 'View in 3D', verify the viewer loads the model. CDN versions (@thatopen/components 2.4.0, three 0.160.0, web-ifc 0.0.66) may need adjustment if the API has changed.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:01Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:04Z","started_at":"2026-04-23T19:54:56Z","closed_at":"2026-04-23T20:05:04Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-49o","type":"blocks","created_at":"2026-04-23T07:14:12Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-ach","title":"Deploy Phase 3b footer template to Forgejo","description":"The footer.tmpl file that injects a 'View in 3D' button on .ifc file pages has been written but not yet deployed. Deploy it to /etc/forgejo/templates/custom/footer.tmpl and verify the button appears when viewing an .ifc file on localhost:3000.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:47:53Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:35Z","closed_at":"2026-04-23T06:14:35Z","close_reason":"Split into atomic tasks: ifcurl-ohq, ifcurl-49o","dependencies":[{"issue_id":"ifcurl-ach","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:40Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"ifcurl-ach","title":"Deploy Phase 3b footer template to Forgejo","description":"The footer.tmpl file that injects a 'View in 3D' button on .ifc file pages has been written but not yet deployed. Deploy it to /etc/forgejo/templates/custom/footer.tmpl and verify the button appears when viewing an .ifc file on localhost:3000.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:47:53Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:35Z","closed_at":"2026-04-23T06:14:35Z","close_reason":"Split into atomic tasks: ifcurl-ohq, ifcurl-49o","dependencies":[{"issue_id":"ifcurl-ach","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:40Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-5u6","title":"Evaluate replacing ifc_url.go markdown extension with JS injection","description":"ifc_url.go is a Goldmark AST extension that replaces ifc:// URLs in rendered markdown with preview figure elements (img + link). This requires patching and recompiling Forgejo. A JS alternative in footer.tmpl could handle the [title](ifc://...) markdown link case by scanning document.querySelectorAll('a[href^=\"ifc://\"]') and replacing matched anchors with figure elements at runtime — no Go changes needed. The limitation: bare ifc://... text in markdown won't be auto-linked by Goldmark (unknown scheme renders as plain text), so bare URLs would need text-node scanning in JS, which is fiddly and causes a render flash. Decision: replace with JS if users can commit to always using [title](ifc://...) link syntax; keep Go extension if bare URLs in markdown are a real use case.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-27T06:05:32Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:05:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-5u6","title":"Evaluate replacing ifc_url.go markdown extension with JS injection","description":"ifc_url.go is a Goldmark AST extension that replaces ifc:// URLs in rendered markdown with preview figure elements (img + link). This requires patching and recompiling Forgejo. A JS alternative in footer.tmpl could handle the [title](ifc://...) markdown link case by scanning document.querySelectorAll('a[href^=\"ifc://\"]') and replacing matched anchors with figure elements at runtime — no Go changes needed. The limitation: bare ifc://... text in markdown won't be auto-linked by Goldmark (unknown scheme renders as plain text), so bare URLs would need text-node scanning in JS, which is fiddly and causes a render flash. Decision: replace with JS if users can commit to always using [title](ifc://...) link syntax; keep Go extension if bare URLs in markdown are a real use case.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-27T06:05:32Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:05:32Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-71f","title":"Extract footer.tmpl JS into a separate ifcurl.js asset file","description":"footer.tmpl is 200 lines of JavaScript wrapped in a single \u003cscript\u003e tag. Moving it to /var/lib/forgejo/custom/public/assets/ifcurl.js (served at /assets/ifcurl.js) would reduce footer.tmpl to a one-liner \u003cscript src='/assets/ifcurl.js'\u003e\u003c/script\u003e. Benefits: proper editor syntax highlighting and linting, no HTML-escaping concerns, cleaner separation of concerns. Two files to deploy instead of one, but that's manageable.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-27T06:05:05Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:05:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-71f","title":"Extract footer.tmpl JS into a separate ifcurl.js asset file","description":"footer.tmpl is 200 lines of JavaScript wrapped in a single \u003cscript\u003e tag. Moving it to /var/lib/forgejo/custom/public/assets/ifcurl.js (served at /assets/ifcurl.js) would reduce footer.tmpl to a one-liner \u003cscript src='/assets/ifcurl.js'\u003e\u003c/script\u003e. Benefits: proper editor syntax highlighting and linting, no HTML-escaping concerns, cleaner separation of concerns. Two files to deploy instead of one, but that's manageable.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:05:05Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:36:55Z","started_at":"2026-04-27T06:33:57Z","closed_at":"2026-04-27T06:36:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-5a6","title":"viewer: wrap ifc:// text in anchor tags for future browser URL handler","description":"At some point we will register a browser protocol handler for ifc:// URLs so they can open desktop tools (Bonsai etc). In preparation, all ifc:// URLs generated alongside embedded snapshots should be wrapped in \u003ca href=\"ifc://...\"\u003e anchor tags. Currently they will not do anything (no handler registered), but having them as links now means the UI is ready when the handler exists, and users can at least see and copy the URL.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T05:36:42Z","created_by":"Bruno Postle","updated_at":"2026-04-27T05:59:25Z","started_at":"2026-04-27T05:39:30Z","closed_at":"2026-04-27T05:59:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"ifcurl-5a6","title":"viewer: wrap ifc:// text in anchor tags for future browser URL handler","description":"At some point we will register a browser protocol handler for ifc:// URLs so they can open desktop tools (Bonsai etc). In preparation, all ifc:// URLs generated alongside embedded snapshots should be wrapped in \u003ca href=\"ifc://...\"\u003e anchor tags. Currently they will not do anything (no handler registered), but having them as links now means the UI is ready when the handler exists, and users can at least see and copy the URL.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T05:36:42Z","created_by":"Bruno Postle","updated_at":"2026-04-27T05:59:25Z","started_at":"2026-04-27T05:39:30Z","closed_at":"2026-04-27T05:59:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-tga","title":"docs: reorganise README to lead with Forgejo features","description":"The project is now primarily a Forgejo mod that enables IFC previews in commit and PR pages. The README should lead with what this enables for users (embedded snapshots, visual diffs, clickable model links), then move into the ifc:// URL specification as the technical foundation. The current README leads with the URL spec which buries the headline feature.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T05:36:41Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:31:47Z","started_at":"2026-04-27T06:07:49Z","closed_at":"2026-04-27T06:31:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-tga","title":"docs: reorganise README to lead with Forgejo features","description":"The project is now primarily a Forgejo mod that enables IFC previews in commit and PR pages. The README should lead with what this enables for users (embedded snapshots, visual diffs, clickable model links), then move into the ifc:// URL specification as the technical foundation. The current README leads with the URL spec which buries the headline feature.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T05:36:41Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:31:47Z","started_at":"2026-04-27T06:07:49Z","closed_at":"2026-04-27T06:31:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-0n8","title":"viewer: storey filter has no 'show all' / reset option","description":"Clicking a storey row in the model overview panel isolates that storey via hider.isolate(). There is no way to reset back to the full model view. Clicking the active storey row should toggle it off (show all), and/or a 'show all' button should appear at the top of the Storeys section when a storey is active. Note: the storey click handler at line ~1016 already has toggle-off logic (if activeRow === row → hider.set(true)) but it only works if you click the same row a second time — the bug is that clicking a different storey, then trying to go back, leaves no 'all storeys' option visible.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-26T07:33:19Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:40:03Z","started_at":"2026-04-26T08:01:13Z","closed_at":"2026-04-26T08:40:03Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-0n8","title":"viewer: storey filter has no 'show all' / reset option","description":"Clicking a storey row in the model overview panel isolates that storey via hider.isolate(). There is no way to reset back to the full model view. Clicking the active storey row should toggle it off (show all), and/or a 'show all' button should appear at the top of the Storeys section when a storey is active. Note: the storey click handler at line ~1016 already has toggle-off logic (if activeRow === row → hider.set(true)) but it only works if you click the same row a second time — the bug is that clicking a different storey, then trying to go back, leaves no 'all storeys' option visible.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-26T07:33:19Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:40:03Z","started_at":"2026-04-26T08:01:13Z","closed_at":"2026-04-26T08:40:03Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -74,7 +74,8 @@ go build -tags 'sqlite sqlite_unlock_notify' \
-o forgejo . && sudo cp forgejo /usr/bin/forgejo -o forgejo . && sudo cp forgejo /usr/bin/forgejo
# Deploy assets (no rebuild needed) # Deploy assets (no rebuild needed)
sudo cp forgejo/custom/public/assets/viewer*.* /etc/forgejo/public/assets/ sudo cp forgejo/custom/public/assets/viewer*.* /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/custom/public/assets/ifcurl.js /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/templates/custom/footer.tmpl /var/lib/forgejo/custom/templates/custom/ sudo cp forgejo/templates/custom/footer.tmpl /var/lib/forgejo/custom/templates/custom/
sudo systemctl restart forgejo sudo systemctl restart forgejo

View file

@ -41,8 +41,9 @@ forgejo/
custom/public/assets/ custom/public/assets/
viewer.html ← browser IFC viewer (no rebuild needed) viewer.html ← browser IFC viewer (no rebuild needed)
viewer-url.js ← viewer URL logic module viewer-url.js ← viewer URL logic module
ifcurl.js ← "View in 3D" + PR diff injection (no rebuild needed)
templates/custom/ templates/custom/
footer.tmpl ← "View in 3D" + PR diff injection (no rebuild needed) footer.tmpl ← loads ifcurl.js (no rebuild needed)
server-config/ server-config/
ifcurl-api.service ← systemd unit for the API service (git + caching) ifcurl-api.service ← systemd unit for the API service (git + caching)
ifcurl-render.service ← systemd unit for the render isolation service ifcurl-render.service ← systemd unit for the render isolation service
@ -174,6 +175,7 @@ sudo cp forgejo/custom/public/assets/viewer-deps.js /var/lib/forgejo/custo
sudo cp forgejo/custom/public/assets/fragments-worker.js /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/fragments-worker.js /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/custom/public/assets/web-ifc.wasm /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/web-ifc.wasm /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/custom/public/assets/web-ifc-mt.wasm /var/lib/forgejo/custom/public/assets/ sudo cp forgejo/custom/public/assets/web-ifc-mt.wasm /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/custom/public/assets/ifcurl.js /var/lib/forgejo/custom/public/assets/
``` ```
Served at `/assets/viewer.html`, `/assets/viewer-deps.js`, etc. Served at `/assets/viewer.html`, `/assets/viewer-deps.js`, etc.

View file

@ -0,0 +1,201 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//
// ifcurl — inject "View in 3D" button on .ifc file view and history pages.
//
// Deploy to: /var/lib/forgejo/custom/public/assets/ifcurl.js
//
// Uses commit-hash hrefs (always unambiguous) to construct ifc:// URLs.
(function () {
"use strict";
var VIEWER_BASE = "/assets/viewer.html";
// Parse /owner/repo/src/commit/{40hex}/{treepath}
// Returns {repoPath, hash, treePath} or null.
function parseCommitHref(href) {
var m = href.match(/^\/([^/]+\/[^/]+)\/src\/commit\/([0-9a-f]{40})\/(.+)$/i);
if (!m) return null;
return { repoPath: m[1], hash: m[2], treePath: m[3] };
}
function makeViewerLink(info, label, cls) {
var host = window.location.host;
var ifcUrl = "ifc://" + host + "/" + info.repoPath
+ "@" + info.hash
+ "?path=" + encodeURIComponent(info.treePath);
var a = document.createElement("a");
a.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl);
a.target = "_blank";
a.rel = "noopener noreferrer";
a.className = cls || "ui mini basic button";
a.textContent = label;
return a;
}
function init() {
var host = window.location.host;
// -----------------------------------------------------------------------
// Case 1: single file view — permalink button in .file-header-right.
// -----------------------------------------------------------------------
var btnGroup = document.querySelector(".file-header-right .ui.buttons");
if (btnGroup) {
// Permalink href or current URL when already at a commit.
var a = document.querySelector('a[href*="/src/commit/"]');
var href = a ? a.getAttribute("href") : window.location.pathname;
var info = parseCommitHref(href);
if (info && info.treePath.toLowerCase().endsWith(".ifc")) {
btnGroup.appendChild(makeViewerLink(info, "View in 3D"));
}
return;
}
// -----------------------------------------------------------------------
// Case 3: PR diff page — inject a render_diff image below the header of
// each .ifc file in the diff. Requires Nginx to proxy /render_diff to
// the ifcurl preview service (see forgejo/README.md).
// -----------------------------------------------------------------------
var prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/);
if (prMatch) {
var prRepoPath = prMatch[1];
var prNum = prMatch[2];
fetch("/api/v1/repos/" + prRepoPath + "/pulls/" + prNum)
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function(pr) {
var baseSha = pr.base && pr.base.sha;
var headSha = pr.head && pr.head.sha;
if (!baseSha || !headSha) return;
var origin = window.location.origin;
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
var info = parseCommitHref(a.getAttribute("href"));
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
if (info.hash !== headSha) return;
var baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha
+ "?path=" + encodeURIComponent(info.treePath);
var headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha
+ "?path=" + encodeURIComponent(info.treePath);
var img = document.createElement("img");
img.src = origin + "/render_diff"
+ "?base=" + encodeURIComponent(baseIfc)
+ "&head=" + encodeURIComponent(headIfc);
img.style.cssText = "max-width:100%;display:block";
img.alt = info.treePath;
var imgLink = document.createElement("a");
imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(headIfc);
imgLink.target = "_blank";
imgLink.rel = "noopener noreferrer";
imgLink.appendChild(img);
var code = document.createElement("code");
code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
code.textContent = headIfc;
var urlLink = document.createElement("a");
urlLink.href = headIfc;
urlLink.appendChild(code);
var container = document.createElement("div");
container.style.cssText = "margin:8px 0";
container.appendChild(imgLink);
container.appendChild(urlLink);
var fileBox = a.closest(".diff-file-box");
var header = fileBox && fileBox.querySelector(".file-header");
if (header) {
header.insertAdjacentElement("afterend", container);
} else {
a.insertAdjacentElement("afterend", container);
}
});
})
.catch(function() {}); // silently skip on API error
return;
}
// -----------------------------------------------------------------------
// Case 4: individual commit page — inject a render_diff image (or plain
// preview for added/deleted files) below the header of each .ifc file
// in the diff.
// -----------------------------------------------------------------------
var commitMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i);
if (commitMatch) {
var repoPath = commitMatch[1];
var headSha = commitMatch[2];
var origin = window.location.origin;
// Extract parent SHA from the "parent" commit link at the top of the page.
var parentSha = null;
var parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']");
if (parentLink) {
var pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i);
if (pm) parentSha = pm[1];
}
document.querySelectorAll(".diff-file-box").forEach(function(box) {
var newFile = box.getAttribute("data-new-filename") || "";
var oldFile = box.getAttribute("data-old-filename") || "";
var newIsIfc = newFile.toLowerCase().endsWith(".ifc");
var oldIsIfc = oldFile.toLowerCase().endsWith(".ifc");
var ifcPath, imgSrc, viewerIfc;
if (newIsIfc && oldIsIfc && newFile === oldFile && parentSha) {
// Modified: show colour-coded diff
ifcPath = newFile;
var baseIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath);
viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath);
imgSrc = origin + "/render_diff?base=" + encodeURIComponent(baseIfc) + "&head=" + encodeURIComponent(viewerIfc);
} else if (newIsIfc) {
// Added (or renamed to .ifc): plain preview of new version
ifcPath = newFile;
viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath);
imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc);
} else if (oldIsIfc && parentSha) {
// Deleted (or renamed from .ifc): plain preview of old version
ifcPath = oldFile;
viewerIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath);
imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc);
} else {
return;
}
var img = document.createElement("img");
img.src = imgSrc;
img.style.cssText = "max-width:100%;display:block";
img.alt = ifcPath;
var imgLink = document.createElement("a");
imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(viewerIfc);
imgLink.target = "_blank";
imgLink.rel = "noopener noreferrer";
imgLink.appendChild(img);
var code = document.createElement("code");
code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
code.textContent = viewerIfc;
var urlLink = document.createElement("a");
urlLink.href = viewerIfc;
urlLink.appendChild(code);
var container = document.createElement("div");
container.style.cssText = "margin:8px 0";
container.appendChild(imgLink);
container.appendChild(urlLink);
var header = box.querySelector(".diff-file-header");
if (header) header.insertAdjacentElement("afterend", container);
});
return;
}
// -----------------------------------------------------------------------
// Case 2: file history page — inject a "3D" link next to each commit's
// browse-file link (/src/commit/{hash}/{treepath}).
// -----------------------------------------------------------------------
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
var info = parseCommitHref(a.getAttribute("href"));
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
var btn = makeViewerLink(info, "3D");
btn.style.marginLeft = "4px";
a.insertAdjacentElement("afterend", btn);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View file

@ -1,203 +1 @@
<script> <script src="/assets/ifcurl.js"></script>
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//
// ifcurl — inject "View in 3D" button on .ifc file view and history pages.
//
// Deploy to: /etc/forgejo/templates/custom/footer.tmpl
//
// Uses commit-hash hrefs (always unambiguous) to construct ifc:// URLs.
(function () {
"use strict";
var VIEWER_BASE = "/assets/viewer.html";
// Parse /owner/repo/src/commit/{40hex}/{treepath}
// Returns {repoPath, hash, treePath} or null.
function parseCommitHref(href) {
var m = href.match(/^\/([^/]+\/[^/]+)\/src\/commit\/([0-9a-f]{40})\/(.+)$/i);
if (!m) return null;
return { repoPath: m[1], hash: m[2], treePath: m[3] };
}
function makeViewerLink(info, label, cls) {
var host = window.location.host;
var ifcUrl = "ifc://" + host + "/" + info.repoPath
+ "@" + info.hash
+ "?path=" + encodeURIComponent(info.treePath);
var a = document.createElement("a");
a.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl);
a.target = "_blank";
a.rel = "noopener noreferrer";
a.className = cls || "ui mini basic button";
a.textContent = label;
return a;
}
function init() {
var host = window.location.host;
// -----------------------------------------------------------------------
// Case 1: single file view — permalink button in .file-header-right.
// -----------------------------------------------------------------------
var btnGroup = document.querySelector(".file-header-right .ui.buttons");
if (btnGroup) {
// Permalink href or current URL when already at a commit.
var a = document.querySelector('a[href*="/src/commit/"]');
var href = a ? a.getAttribute("href") : window.location.pathname;
var info = parseCommitHref(href);
if (info && info.treePath.toLowerCase().endsWith(".ifc")) {
btnGroup.appendChild(makeViewerLink(info, "View in 3D"));
}
return;
}
// -----------------------------------------------------------------------
// Case 3: PR diff page — inject a render_diff image below the header of
// each .ifc file in the diff. Requires Nginx to proxy /render_diff to
// the ifcurl preview service (see forgejo/README.md).
// -----------------------------------------------------------------------
var prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/);
if (prMatch) {
var prRepoPath = prMatch[1];
var prNum = prMatch[2];
fetch("/api/v1/repos/" + prRepoPath + "/pulls/" + prNum)
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function(pr) {
var baseSha = pr.base && pr.base.sha;
var headSha = pr.head && pr.head.sha;
if (!baseSha || !headSha) return;
var origin = window.location.origin;
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
var info = parseCommitHref(a.getAttribute("href"));
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
if (info.hash !== headSha) return;
var baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha
+ "?path=" + encodeURIComponent(info.treePath);
var headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha
+ "?path=" + encodeURIComponent(info.treePath);
var img = document.createElement("img");
img.src = origin + "/render_diff"
+ "?base=" + encodeURIComponent(baseIfc)
+ "&head=" + encodeURIComponent(headIfc);
img.style.cssText = "max-width:100%;display:block";
img.alt = info.treePath;
var imgLink = document.createElement("a");
imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(headIfc);
imgLink.target = "_blank";
imgLink.rel = "noopener noreferrer";
imgLink.appendChild(img);
var code = document.createElement("code");
code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
code.textContent = headIfc;
var urlLink = document.createElement("a");
urlLink.href = headIfc;
urlLink.appendChild(code);
var container = document.createElement("div");
container.style.cssText = "margin:8px 0";
container.appendChild(imgLink);
container.appendChild(urlLink);
var fileBox = a.closest(".diff-file-box");
var header = fileBox && fileBox.querySelector(".file-header");
if (header) {
header.insertAdjacentElement("afterend", container);
} else {
a.insertAdjacentElement("afterend", container);
}
});
})
.catch(function() {}); // silently skip on API error
return;
}
// -----------------------------------------------------------------------
// Case 4: individual commit page — inject a render_diff image (or plain
// preview for added/deleted files) below the header of each .ifc file
// in the diff.
// -----------------------------------------------------------------------
var commitMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i);
if (commitMatch) {
var repoPath = commitMatch[1];
var headSha = commitMatch[2];
var origin = window.location.origin;
// Extract parent SHA from the "parent" commit link at the top of the page.
var parentSha = null;
var parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']");
if (parentLink) {
var pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i);
if (pm) parentSha = pm[1];
}
document.querySelectorAll(".diff-file-box").forEach(function(box) {
var newFile = box.getAttribute("data-new-filename") || "";
var oldFile = box.getAttribute("data-old-filename") || "";
var newIsIfc = newFile.toLowerCase().endsWith(".ifc");
var oldIsIfc = oldFile.toLowerCase().endsWith(".ifc");
var ifcPath, imgSrc, viewerIfc;
if (newIsIfc && oldIsIfc && newFile === oldFile && parentSha) {
// Modified: show colour-coded diff
ifcPath = newFile;
var baseIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath);
viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath);
imgSrc = origin + "/render_diff?base=" + encodeURIComponent(baseIfc) + "&head=" + encodeURIComponent(viewerIfc);
} else if (newIsIfc) {
// Added (or renamed to .ifc): plain preview of new version
ifcPath = newFile;
viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath);
imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc);
} else if (oldIsIfc && parentSha) {
// Deleted (or renamed from .ifc): plain preview of old version
ifcPath = oldFile;
viewerIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath);
imgSrc = origin + "/preview?url=" + encodeURIComponent(viewerIfc);
} else {
return;
}
var img = document.createElement("img");
img.src = imgSrc;
img.style.cssText = "max-width:100%;display:block";
img.alt = ifcPath;
var imgLink = document.createElement("a");
imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(viewerIfc);
imgLink.target = "_blank";
imgLink.rel = "noopener noreferrer";
imgLink.appendChild(img);
var code = document.createElement("code");
code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
code.textContent = viewerIfc;
var urlLink = document.createElement("a");
urlLink.href = viewerIfc;
urlLink.appendChild(code);
var container = document.createElement("div");
container.style.cssText = "margin:8px 0";
container.appendChild(imgLink);
container.appendChild(urlLink);
var header = box.querySelector(".diff-file-header");
if (header) header.insertAdjacentElement("afterend", container);
});
return;
}
// -----------------------------------------------------------------------
// Case 2: file history page — inject a "3D" link next to each commit's
// browse-file link (/src/commit/{hash}/{treepath}).
// -----------------------------------------------------------------------
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
var info = parseCommitHref(a.getAttribute("href"));
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
var btn = makeViewerLink(info, "3D");
btn.style.marginLeft = "4px";
a.insertAdjacentElement("afterend", btn);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>