diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index 4c0aa9e..b6b9517 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -105,3 +105,4 @@
{"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."}
+{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."}
diff --git a/forgejo/README.md b/forgejo/README.md
index 36ff5aa..569ed12 100644
--- a/forgejo/README.md
+++ b/forgejo/README.md
@@ -43,8 +43,9 @@ forgejo/
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
- ifcurl.js ← "View in 3D" + PR diff injection (no rebuild needed)
+ viewer-url.js ← ifc:// URL parse/build/resolve logic (ES module)
+ viewer-util.js ← shared pure utilities: parseCommitHref, isSimpleTypeSelector, etc.
+ ifcurl.js ← "View in 3D" + PR diff injection (ES module, no rebuild needed)
templates/custom/
footer.tmpl ← loads ifcurl.js (no rebuild needed)
server-config/
@@ -137,6 +138,7 @@ Node.js 18+ and npm (used only at build time; not required on the server).
cd forgejo/
npm install # installs pinned versions from package-lock.json
npm run build # produces the four files below
+npm test # run JavaScript unit tests (viewer-url.js, viewer-util.js)
```
Output files (committed to the repository):
@@ -174,6 +176,7 @@ These files can be updated at any time without recompiling Forgejo.
sudo mkdir -p /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/custom/public/assets/viewer.html /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/custom/public/assets/viewer-url.js /var/lib/forgejo/custom/public/assets/
+sudo cp forgejo/custom/public/assets/viewer-util.js /var/lib/forgejo/custom/public/assets/
sudo cp forgejo/custom/public/assets/viewer-deps.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/
@@ -181,6 +184,8 @@ sudo cp forgejo/custom/public/assets/web-ifc-mt.wasm /var/lib/forgejo/custo
sudo cp forgejo/custom/public/assets/ifcurl.js /var/lib/forgejo/custom/public/assets/
```
+`ifcurl.js` is an ES module and imports from `viewer-util.js` at the same path, so both files must be deployed together.
+
Served at `/assets/viewer.html`, `/assets/viewer-deps.js`, etc.
Note: the correct CustomPath is `/var/lib/forgejo/custom` (Forgejo's default when `FORGEJO_CUSTOM` is
diff --git a/forgejo/custom/public/assets/ifcurl.js b/forgejo/custom/public/assets/ifcurl.js
index 843fa3c..b95e9f5 100644
--- a/forgejo/custom/public/assets/ifcurl.js
+++ b/forgejo/custom/public/assets/ifcurl.js
@@ -6,233 +6,220 @@
// Deploy to: /var/lib/forgejo/custom/public/assets/ifcurl.js
//
// Uses commit-hash hrefs (always unambiguous) to construct ifc:// URLs.
-(function () {
- "use strict";
+import { parseCommitHref } from "./viewer-util.js";
- var VIEWER_BASE = "/assets/viewer.html";
+const 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
+function makeViewerLink(info, label, cls) {
+ const host = window.location.host;
+ const 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;
+ const 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;
+}
+
+// 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).
+function replaceIfcAnchors() {
+ const origin = window.location.origin;
+ document.querySelectorAll('a[href^="ifc://"]').forEach(function(a) {
+ if (a.closest(".ifcurl-preview")) return;
+ const ifcUrl = a.getAttribute("href");
+ const img = document.createElement("img");
+ img.src = origin + "/preview?url=" + encodeURIComponent(ifcUrl);
+ img.alt = "IFC preview";
+ img.loading = "lazy";
+ img.style.maxWidth = "100%";
+ const imgLink = document.createElement("a");
+ imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl);
+ imgLink.title = ifcUrl;
+ imgLink.target = "_blank";
+ imgLink.rel = "noopener noreferrer";
+ imgLink.appendChild(img);
+ const code = document.createElement("code");
+ code.textContent = ifcUrl;
+ const urlLink = document.createElement("a");
+ urlLink.href = ifcUrl;
+ urlLink.appendChild(code);
+ const caption = document.createElement("figcaption");
+ caption.appendChild(urlLink);
+ const figure = document.createElement("figure");
+ figure.className = "ifcurl-preview";
+ figure.appendChild(imgLink);
+ figure.appendChild(caption);
+ a.parentNode.replaceChild(figure, a);
+ });
+}
+
+function init() {
+ const host = window.location.host;
+
+ replaceIfcAnchors();
+
+ // -----------------------------------------------------------------------
+ // Case 1: single file view — permalink button in .file-header-right.
+ // -----------------------------------------------------------------------
+ const btnGroup = document.querySelector(".file-header-right .ui.buttons");
+ if (btnGroup) {
+ // Permalink href or current URL when already at a commit.
+ const a = document.querySelector('a[href*="/src/commit/"]');
+ const href = a ? a.getAttribute("href") : window.location.pathname;
+ const info = parseCommitHref(href);
+ if (info && info.treePath.toLowerCase().endsWith(".ifc")) {
+ btnGroup.appendChild(makeViewerLink(info, "View in 3D"));
+ }
+ return;
}
- // 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).
- function replaceIfcAnchors() {
- var origin = window.location.origin;
- document.querySelectorAll('a[href^="ifc://"]').forEach(function(a) {
- if (a.closest(".ifcurl-preview")) return;
- var ifcUrl = a.getAttribute("href");
- var img = document.createElement("img");
- img.src = origin + "/preview?url=" + encodeURIComponent(ifcUrl);
- img.alt = "IFC preview";
- img.loading = "lazy";
- img.style.maxWidth = "100%";
- var imgLink = document.createElement("a");
- imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl);
- imgLink.title = ifcUrl;
+ // -----------------------------------------------------------------------
+ // 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).
+ // -----------------------------------------------------------------------
+ const prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/);
+ if (prMatch) {
+ const prRepoPath = prMatch[1];
+ const 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) {
+ const baseSha = pr.base && pr.base.sha;
+ const headSha = pr.head && pr.head.sha;
+ if (!baseSha || !headSha) return;
+ const origin = window.location.origin;
+ document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
+ const info = parseCommitHref(a.getAttribute("href"));
+ if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
+ if (info.hash !== headSha) return;
+ const baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha
+ + "?path=" + encodeURIComponent(info.treePath);
+ const headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha
+ + "?path=" + encodeURIComponent(info.treePath);
+ const 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;
+ const imgLink = document.createElement("a");
+ imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(headIfc);
+ imgLink.target = "_blank";
+ imgLink.rel = "noopener noreferrer";
+ imgLink.appendChild(img);
+ const code = document.createElement("code");
+ code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
+ code.textContent = headIfc;
+ const urlLink = document.createElement("a");
+ urlLink.href = headIfc;
+ urlLink.appendChild(code);
+ const container = document.createElement("div");
+ container.style.cssText = "margin:8px 0";
+ container.appendChild(imgLink);
+ container.appendChild(urlLink);
+ const fileBox = a.closest(".diff-file-box");
+ const 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.
+ // -----------------------------------------------------------------------
+ const commitMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i);
+ if (commitMatch) {
+ const repoPath = commitMatch[1];
+ const headSha = commitMatch[2];
+ const origin = window.location.origin;
+
+ // Extract parent SHA from the "parent" commit link at the top of the page.
+ let parentSha = null;
+ const parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']");
+ if (parentLink) {
+ const pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i);
+ if (pm) parentSha = pm[1];
+ }
+
+ document.querySelectorAll(".diff-file-box").forEach(function(box) {
+ const newFile = box.getAttribute("data-new-filename") || "";
+ const oldFile = box.getAttribute("data-old-filename") || "";
+ const newIsIfc = newFile.toLowerCase().endsWith(".ifc");
+ const oldIsIfc = oldFile.toLowerCase().endsWith(".ifc");
+
+ let ifcPath, imgSrc, viewerIfc;
+ if (newIsIfc && oldIsIfc && newFile === oldFile && parentSha) {
+ // Modified: show colour-coded diff
+ ifcPath = newFile;
+ const 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;
+ }
+
+ const img = document.createElement("img");
+ img.src = imgSrc;
+ img.style.cssText = "max-width:100%;display:block";
+ img.alt = ifcPath;
+ const 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.textContent = ifcUrl;
- var urlLink = document.createElement("a");
- urlLink.href = ifcUrl;
+ const code = document.createElement("code");
+ code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
+ code.textContent = viewerIfc;
+ const urlLink = document.createElement("a");
+ urlLink.href = viewerIfc;
urlLink.appendChild(code);
- var caption = document.createElement("figcaption");
- caption.appendChild(urlLink);
- var figure = document.createElement("figure");
- figure.className = "ifcurl-preview";
- figure.appendChild(imgLink);
- figure.appendChild(caption);
- a.parentNode.replaceChild(figure, a);
+ const container = document.createElement("div");
+ container.style.cssText = "margin:8px 0";
+ container.appendChild(imgLink);
+ container.appendChild(urlLink);
+ const header = box.querySelector(".diff-file-header");
+ if (header) header.insertAdjacentElement("afterend", container);
});
+ return;
}
- function init() {
- var host = window.location.host;
+ // -----------------------------------------------------------------------
+ // 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) {
+ const info = parseCommitHref(a.getAttribute("href"));
+ if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
+ const btn = makeViewerLink(info, "3D");
+ btn.style.marginLeft = "4px";
+ a.insertAdjacentElement("afterend", btn);
+ });
+}
- replaceIfcAnchors();
-
- // -----------------------------------------------------------------------
- // 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();
- }
-})();
+// ES modules are deferred by default, so the DOM is always ready here.
+init();
diff --git a/forgejo/custom/public/assets/viewer-util.js b/forgejo/custom/public/assets/viewer-util.js
new file mode 100644
index 0000000..c96aa53
--- /dev/null
+++ b/forgejo/custom/public/assets/viewer-util.js
@@ -0,0 +1,39 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+//
+// Pure utility functions shared between viewer.js, ifcurl.js, and tests.
+
+// Parse a Forgejo browse path: /owner/repo/src/commit/{40hex}/{treepath}
+// Returns {repoPath, hash, treePath} or null.
+export function parseCommitHref(href) {
+ const m = href.match(/^\/([^/]+\/[^/]+)\/src\/commit\/([0-9a-f]{40})\/(.+)$/i);
+ if (!m) return null;
+ return { repoPath: m[1], hash: m[2], treePath: m[3] };
+}
+
+// Returns true if the selector contains only IFC type names joined by "+".
+// E.g. "IfcWall", "IfcWall+IfcSlab". Property filters and name= require
+// server-side resolution via /select.
+export function isSimpleTypeSelector(selectorStr) {
+ return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim()));
+}
+
+// Set-subtract toRemove from allItems across all model IDs.
+// Both arguments are ModelIdMap: { [modelId]: Iterable }.
+export function subtractIdMap(allItems, toRemove) {
+ const result = {};
+ for (const [modelId, allIds] of Object.entries(allItems)) {
+ const removeSet = new Set(toRemove[modelId] ?? []);
+ const remaining = [];
+ for (const id of allIds) {
+ if (!removeSet.has(id)) remaining.push(id);
+ }
+ if (remaining.length > 0) result[modelId] = remaining;
+ }
+ return result;
+}
+
+// Returns true if ref looks like a git commit hash (7–40 hex chars).
+export function isCommitHash(ref) {
+ return /^[0-9a-f]{7,40}$/.test(ref);
+}
diff --git a/forgejo/custom/public/assets/viewer.js b/forgejo/custom/public/assets/viewer.js
index 05fc3de..04aaa73 100644
--- a/forgejo/custom/public/assets/viewer.js
+++ b/forgejo/custom/public/assets/viewer.js
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: MIT
import { THREE, OBC, JSZip } from "/assets/viewer-deps.js";
import { parseIfcUrl, toRawUrl, buildIfcUrl as _buildIfcUrl } from "./viewer-url.js";
+import { isSimpleTypeSelector, subtractIdMap, isCommitHash } from "./viewer-util.js";
const params = new URLSearchParams(window.location.search);
const ifcUrl = params.get("url") ?? "";
@@ -459,23 +460,6 @@ function setupClipper(components, world, clips) {
// server-side via GET /select, then applied by GUID in the viewer.
// Requires the ifcurl service to be running.
// -----------------------------------------------------------------------
-function isSimpleTypeSelector(selectorStr) {
- return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim()));
-}
-
-// Returns a ModelIdMap containing all items in allItems that are NOT in toRemove.
-function subtractIdMap(allItems, toRemove) {
- const result = {};
- for (const [modelId, allIds] of Object.entries(allItems)) {
- const removeSet = new Set(toRemove[modelId] ?? []);
- const remaining = [];
- for (const id of allIds) {
- if (!removeSet.has(id)) remaining.push(id);
- }
- if (remaining.length > 0) result[modelId] = remaining;
- }
- return result;
-}
// Apply a fragment material style to the given items via FragmentsManager.
async function applyFragmentStyle(components, items, color, opacity) {
@@ -982,10 +966,6 @@ async function populateRefList(host, repoSuffix) {
// -----------------------------------------------------------------------
// Commit-pin indicator helpers.
// -----------------------------------------------------------------------
-function isCommitHash(ref) {
- return /^[0-9a-f]{7,40}$/.test(ref);
-}
-
async function fetchBranchForCommit(host, repoSuffix, hash) {
const proto = host.startsWith("localhost") ? "http" : "https";
const apiBase = `${proto}://${host}/api/v1/repos/${repoSuffix}`;
diff --git a/forgejo/package.json b/forgejo/package.json
index c3f90bf..7d3ddf9 100644
--- a/forgejo/package.json
+++ b/forgejo/package.json
@@ -3,7 +3,8 @@
"private": true,
"type": "module",
"scripts": {
- "build": "node build.js"
+ "build": "node build.js",
+ "test": "node --test tests/*.test.js"
},
"dependencies": {
"@thatopen/components": "3.4.2",
diff --git a/forgejo/templates/custom/footer.tmpl b/forgejo/templates/custom/footer.tmpl
index 37985b3..885f4d0 100644
--- a/forgejo/templates/custom/footer.tmpl
+++ b/forgejo/templates/custom/footer.tmpl
@@ -1 +1 @@
-
+
diff --git a/forgejo/tests/viewer-url.test.js b/forgejo/tests/viewer-url.test.js
new file mode 100644
index 0000000..54a4b44
--- /dev/null
+++ b/forgejo/tests/viewer-url.test.js
@@ -0,0 +1,203 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { parseIfcUrl, buildIfcUrl, toRawUrl } from "../custom/public/assets/viewer-url.js";
+
+// ---------------------------------------------------------------------------
+// parseIfcUrl
+// ---------------------------------------------------------------------------
+
+describe("parseIfcUrl", () => {
+ test("returns null for non-ifc URLs", () => {
+ assert.equal(parseIfcUrl("https://example.com"), null);
+ assert.equal(parseIfcUrl(""), null);
+ assert.equal(parseIfcUrl(null), null);
+ assert.equal(parseIfcUrl(undefined), null);
+ });
+
+ test("parses host and repo path", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?path=model.ifc");
+ assert.equal(r.host, "example.com");
+ assert.equal(r.repoSuffix, "org/repo");
+ assert.equal(r.userAt, "");
+ });
+
+ test("parses user@ authority", () => {
+ const r = parseIfcUrl("ifc://git@example.com/org/repo@heads/main?path=model.ifc");
+ assert.equal(r.userAt, "git@");
+ assert.equal(r.host, "example.com");
+ assert.equal(r.repo, "git@example.com/org/repo");
+ });
+
+ test("parses heads/ ref", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?path=a.ifc");
+ assert.equal(r.ref, "heads/main");
+ });
+
+ test("parses tags/ ref", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@tags/v1.0?path=a.ifc");
+ assert.equal(r.ref, "tags/v1.0");
+ });
+
+ test("parses bare commit hash ref", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@abc123def456?path=a.ifc");
+ assert.equal(r.ref, "abc123def456");
+ });
+
+ test("parses path parameter", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?path=subdir/model.ifc");
+ assert.equal(r.path, "subdir/model.ifc");
+ });
+
+ test("parses selector parameter", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?selector=IfcWall");
+ assert.equal(r.selector, "IfcWall");
+ });
+
+ test("parses camera parameter", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?camera=1,2,3,4,5,6,7,8,9");
+ assert.equal(r.camera, "1,2,3,4,5,6,7,8,9");
+ });
+
+ test("parses fov parameter", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?fov=60");
+ assert.equal(r.fov, "60");
+ });
+
+ test("parses scale parameter", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?scale=0.01");
+ assert.equal(r.scale, "0.01");
+ });
+
+ test("defaults missing parameters to empty string", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main");
+ assert.equal(r.path, "");
+ assert.equal(r.selector, "");
+ assert.equal(r.camera, "");
+ assert.equal(r.fov, "");
+ assert.equal(r.scale, "");
+ });
+
+ test("builds repo field as userAt + host + / + repoSuffix", () => {
+ const r = parseIfcUrl("ifc://example.com/org/repo@heads/main");
+ assert.equal(r.repo, "example.com/org/repo");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildIfcUrl
+// ---------------------------------------------------------------------------
+
+describe("buildIfcUrl", () => {
+ test("returns null when repo is missing", () => {
+ assert.equal(buildIfcUrl("", "heads/main", "model.ifc", ""), null);
+ assert.equal(buildIfcUrl(null, "heads/main", "", ""), null);
+ });
+
+ test("returns null when ref is missing", () => {
+ assert.equal(buildIfcUrl("example.com/org/repo", "", "model.ifc", ""), null);
+ assert.equal(buildIfcUrl("example.com/org/repo", null, "", ""), null);
+ });
+
+ test("builds minimal URL with no path or selector", () => {
+ const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "");
+ assert.equal(url, "ifc://example.com/org/repo@heads/main");
+ });
+
+ test("includes path in query string", () => {
+ const url = buildIfcUrl("example.com/org/repo", "heads/main", "model.ifc", "");
+ assert.equal(url, "ifc://example.com/org/repo@heads/main?path=model.ifc");
+ });
+
+ test("includes selector in query string", () => {
+ const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "IfcWall");
+ assert.equal(url, "ifc://example.com/org/repo@heads/main?selector=IfcWall");
+ });
+
+ test("includes both path and selector", () => {
+ const url = buildIfcUrl("example.com/org/repo", "heads/main", "model.ifc", "IfcWall");
+ assert.equal(url, "ifc://example.com/org/repo@heads/main?path=model.ifc&selector=IfcWall");
+ });
+
+ test("does not percent-encode commas in selector", () => {
+ const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "IfcWall,Name=\"Core\"");
+ assert.ok(url.includes(","), "comma should not be encoded");
+ assert.ok(!url.includes("%2C"), "%2C should not appear");
+ });
+
+ test("does not percent-encode + in selector", () => {
+ const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "IfcWall+IfcSlab");
+ assert.ok(!url.includes("%2B"), "%2B should not appear");
+ });
+
+ test("does not percent-encode $ in selector (GlobalId syntax)", () => {
+ const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "325Q7Fhnf67OZC$$r43uzK");
+ assert.ok(!url.includes("%24"), "%24 should not appear");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// toRawUrl
+// ---------------------------------------------------------------------------
+
+describe("toRawUrl", () => {
+ // GitHub
+ test("generates GitHub raw URL for heads/ ref", () => {
+ const url = toRawUrl("ifc://github.com/org/repo@heads/main?path=model.ifc");
+ assert.equal(url, "https://raw.githubusercontent.com/org/repo/main/model.ifc");
+ });
+
+ test("generates GitHub raw URL for tags/ ref", () => {
+ const url = toRawUrl("ifc://github.com/org/repo@tags/v1.0?path=model.ifc");
+ assert.equal(url, "https://raw.githubusercontent.com/org/repo/v1.0/model.ifc");
+ });
+
+ test("generates GitHub raw URL for bare commit hash", () => {
+ const url = toRawUrl("ifc://github.com/org/repo@abc123?path=model.ifc");
+ assert.equal(url, "https://raw.githubusercontent.com/org/repo/abc123/model.ifc");
+ });
+
+ // GitLab
+ test("generates GitLab raw URL for heads/ ref", () => {
+ const url = toRawUrl("ifc://gitlab.com/org/repo@heads/main?path=model.ifc");
+ assert.equal(url, "https://gitlab.com/org/repo/-/raw/main/model.ifc");
+ });
+
+ test("generates GitLab raw URL for tags/ ref", () => {
+ const url = toRawUrl("ifc://gitlab.com/org/repo@tags/v1.0?path=model.ifc");
+ assert.equal(url, "https://gitlab.com/org/repo/-/raw/v1.0/model.ifc");
+ });
+
+ // Forgejo / Gitea (codeberg.org, self-hosted, etc.)
+ test("generates Forgejo raw URL for heads/ ref", () => {
+ const url = toRawUrl("ifc://codeberg.org/org/repo@heads/main?path=model.ifc");
+ assert.equal(url, "https://codeberg.org/org/repo/raw/branch/main/model.ifc");
+ });
+
+ test("generates Forgejo raw URL for tags/ ref", () => {
+ const url = toRawUrl("ifc://codeberg.org/org/repo@tags/v1.0?path=model.ifc");
+ assert.equal(url, "https://codeberg.org/org/repo/raw/tag/v1.0/model.ifc");
+ });
+
+ test("generates Forgejo raw URL for bare commit hash", () => {
+ const url = toRawUrl("ifc://codeberg.org/org/repo@abc123?path=model.ifc");
+ assert.equal(url, "https://codeberg.org/org/repo/raw/commit/abc123/model.ifc");
+ });
+
+ test("generates Forgejo raw URL for self-hosted instance", () => {
+ const url = toRawUrl("ifc://git.example.com/org/repo@heads/main?path=model.ifc");
+ assert.equal(url, "https://git.example.com/org/repo/raw/branch/main/model.ifc");
+ });
+
+ test("uses http for localhost Forgejo (local dev instance)", () => {
+ const url = toRawUrl("ifc://localhost:3000/org/repo@heads/main?path=model.ifc");
+ assert.equal(url, "http://localhost:3000/org/repo/raw/branch/main/model.ifc");
+ });
+
+ test("includes subdirectory file path", () => {
+ const url = toRawUrl("ifc://codeberg.org/org/repo@heads/main?path=subdir/model.ifc");
+ assert.ok(url.endsWith("/subdir/model.ifc"), `expected path suffix, got: ${url}`);
+ });
+});
diff --git a/forgejo/tests/viewer-util.test.js b/forgejo/tests/viewer-util.test.js
new file mode 100644
index 0000000..f8572aa
--- /dev/null
+++ b/forgejo/tests/viewer-util.test.js
@@ -0,0 +1,162 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { parseCommitHref, isSimpleTypeSelector, subtractIdMap, isCommitHash }
+ from "../custom/public/assets/viewer-util.js";
+
+// ---------------------------------------------------------------------------
+// parseCommitHref
+// ---------------------------------------------------------------------------
+
+describe("parseCommitHref", () => {
+ test("parses a standard Forgejo browse path", () => {
+ const r = parseCommitHref("/org/repo/src/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/model.ifc");
+ assert.equal(r.repoPath, "org/repo");
+ assert.equal(r.hash, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2");
+ assert.equal(r.treePath, "model.ifc");
+ });
+
+ test("parses a path with subdirectories", () => {
+ const r = parseCommitHref("/org/repo/src/commit/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/subdir/model.ifc");
+ assert.equal(r.treePath, "subdir/model.ifc");
+ });
+
+ test("returns null for non-commit paths", () => {
+ assert.equal(parseCommitHref("/org/repo/src/branch/main/model.ifc"), null);
+ assert.equal(parseCommitHref("/org/repo"), null);
+ assert.equal(parseCommitHref(""), null);
+ });
+
+ test("returns null if hash is not 40 hex chars", () => {
+ assert.equal(parseCommitHref("/org/repo/src/commit/abc123/model.ifc"), null);
+ });
+
+ test("is case-insensitive for hex hash", () => {
+ const r = parseCommitHref("/org/repo/src/commit/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2/model.ifc");
+ assert.ok(r !== null);
+ assert.equal(r.hash, "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isSimpleTypeSelector
+// ---------------------------------------------------------------------------
+
+describe("isSimpleTypeSelector", () => {
+ test("accepts a single IFC type", () => {
+ assert.equal(isSimpleTypeSelector("IfcWall"), true);
+ });
+
+ test("accepts multiple types joined by +", () => {
+ assert.equal(isSimpleTypeSelector("IfcWall+IfcSlab"), true);
+ assert.equal(isSimpleTypeSelector("IfcWall+IfcSlab+IfcBeam"), true);
+ });
+
+ test("accepts subtypes", () => {
+ assert.equal(isSimpleTypeSelector("IfcWallStandardCase"), true);
+ });
+
+ test("accepts lowercase ifc prefix", () => {
+ assert.equal(isSimpleTypeSelector("ifcWall"), true);
+ });
+
+ test("rejects name filter", () => {
+ assert.equal(isSimpleTypeSelector('IfcWall, Name="Core Wall"'), false);
+ });
+
+ test("rejects property set filter", () => {
+ assert.equal(isSimpleTypeSelector("IfcWall[Pset_WallCommon.IsExternal=true]"), false);
+ });
+
+ test("rejects bare GlobalId (not an IFC type name)", () => {
+ assert.equal(isSimpleTypeSelector("325Q7Fhnf67OZC$$r43uzK"), false);
+ });
+
+ test("rejects empty string", () => {
+ assert.equal(isSimpleTypeSelector(""), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// subtractIdMap
+// ---------------------------------------------------------------------------
+
+describe("subtractIdMap", () => {
+ test("removes ids present in toRemove", () => {
+ const all = { m1: [1, 2, 3, 4] };
+ const remove = { m1: [2, 4] };
+ const result = subtractIdMap(all, remove);
+ assert.deepEqual(result, { m1: [1, 3] });
+ });
+
+ test("omits model keys where all ids are removed", () => {
+ const all = { m1: [1, 2] };
+ const remove = { m1: [1, 2] };
+ const result = subtractIdMap(all, remove);
+ assert.deepEqual(result, {});
+ });
+
+ test("leaves models untouched that are not in toRemove", () => {
+ const all = { m1: [1, 2], m2: [3, 4] };
+ const remove = { m1: [1] };
+ const result = subtractIdMap(all, remove);
+ assert.deepEqual(result, { m1: [2], m2: [3, 4] });
+ });
+
+ test("handles empty toRemove", () => {
+ const all = { m1: [1, 2, 3] };
+ const result = subtractIdMap(all, {});
+ assert.deepEqual(result, { m1: [1, 2, 3] });
+ });
+
+ test("handles multiple models in toRemove", () => {
+ const all = { m1: [1, 2], m2: [3, 4] };
+ const remove = { m1: [2], m2: [3] };
+ const result = subtractIdMap(all, remove);
+ assert.deepEqual(result, { m1: [1], m2: [4] });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isCommitHash
+// ---------------------------------------------------------------------------
+
+describe("isCommitHash", () => {
+ test("accepts a full 40-char SHA", () => {
+ assert.equal(isCommitHash("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), true);
+ });
+
+ test("accepts a 7-char short SHA", () => {
+ assert.equal(isCommitHash("a1b2c3d"), true);
+ });
+
+ test("accepts lengths between 7 and 40", () => {
+ assert.equal(isCommitHash("a1b2c3d4e5f6"), true);
+ });
+
+ test("rejects heads/ ref", () => {
+ assert.equal(isCommitHash("heads/main"), false);
+ });
+
+ test("rejects tags/ ref", () => {
+ assert.equal(isCommitHash("tags/v1.0"), false);
+ });
+
+ test("rejects uppercase hex (hashes are lowercase)", () => {
+ assert.equal(isCommitHash("A1B2C3D"), false);
+ });
+
+ test("rejects fewer than 7 chars", () => {
+ assert.equal(isCommitHash("a1b2c3"), false);
+ });
+
+ test("rejects more than 40 chars", () => {
+ assert.equal(isCommitHash("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2x"), false);
+ });
+
+ test("rejects empty string", () => {
+ assert.equal(isCommitHash(""), false);
+ });
+});