forgejo: add JS unit tests, extract pure functions into viewer-util.js

Extract parseCommitHref, isSimpleTypeSelector, subtractIdMap, and
isCommitHash into a new viewer-util.js ES module so they can be tested
without a browser. Convert ifcurl.js from an IIFE to an ES module
importing from viewer-util.js; update footer.tmpl to type="module".

Add node:test suites for viewer-url.js (32 tests) and viewer-util.js
(28 tests). Add npm test script and document it in the build section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-27 20:24:58 +01:00
parent a1a7a6c829
commit d60bd044dd
9 changed files with 619 additions and 241 deletions

View file

@ -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-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} {"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":"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."}

View file

@ -43,8 +43,9 @@ forgejo/
ifc_url_test.go ← Go tests (copy into source tree) ifc_url_test.go ← Go tests (copy into source tree)
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 ← ifc:// URL parse/build/resolve logic (ES module)
ifcurl.js ← "View in 3D" + PR diff injection (no rebuild needed) viewer-util.js ← shared pure utilities: parseCommitHref, isSimpleTypeSelector, etc.
ifcurl.js ← "View in 3D" + PR diff injection (ES module, no rebuild needed)
templates/custom/ templates/custom/
footer.tmpl ← loads ifcurl.js (no rebuild needed) footer.tmpl ← loads ifcurl.js (no rebuild needed)
server-config/ server-config/
@ -137,6 +138,7 @@ Node.js 18+ and npm (used only at build time; not required on the server).
cd forgejo/ cd forgejo/
npm install # installs pinned versions from package-lock.json npm install # installs pinned versions from package-lock.json
npm run build # produces the four files below 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): 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 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.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-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/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/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/
@ -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/ 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. 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 Note: the correct CustomPath is `/var/lib/forgejo/custom` (Forgejo's default when `FORGEJO_CUSTOM` is

View file

@ -6,25 +6,16 @@
// Deploy to: /var/lib/forgejo/custom/public/assets/ifcurl.js // Deploy to: /var/lib/forgejo/custom/public/assets/ifcurl.js
// //
// Uses commit-hash hrefs (always unambiguous) to construct ifc:// URLs. // Uses commit-hash hrefs (always unambiguous) to construct ifc:// URLs.
(function () { import { parseCommitHref } from "./viewer-util.js";
"use strict";
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) { function makeViewerLink(info, label, cls) {
var host = window.location.host; const host = window.location.host;
var ifcUrl = "ifc://" + host + "/" + info.repoPath const ifcUrl = "ifc://" + host + "/" + info.repoPath
+ "@" + info.hash + "@" + info.hash
+ "?path=" + encodeURIComponent(info.treePath); + "?path=" + encodeURIComponent(info.treePath);
var a = document.createElement("a"); const a = document.createElement("a");
a.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl); a.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl);
a.target = "_blank"; a.target = "_blank";
a.rel = "noopener noreferrer"; a.rel = "noopener noreferrer";
@ -38,29 +29,29 @@
// no Go extension required. Skips anchors already inside .ifcurl-preview (those // 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). // were rendered server-side by ifc_url.go when the patched binary is in use).
function replaceIfcAnchors() { function replaceIfcAnchors() {
var origin = window.location.origin; const origin = window.location.origin;
document.querySelectorAll('a[href^="ifc://"]').forEach(function(a) { document.querySelectorAll('a[href^="ifc://"]').forEach(function(a) {
if (a.closest(".ifcurl-preview")) return; if (a.closest(".ifcurl-preview")) return;
var ifcUrl = a.getAttribute("href"); const ifcUrl = a.getAttribute("href");
var img = document.createElement("img"); const img = document.createElement("img");
img.src = origin + "/preview?url=" + encodeURIComponent(ifcUrl); img.src = origin + "/preview?url=" + encodeURIComponent(ifcUrl);
img.alt = "IFC preview"; img.alt = "IFC preview";
img.loading = "lazy"; img.loading = "lazy";
img.style.maxWidth = "100%"; img.style.maxWidth = "100%";
var imgLink = document.createElement("a"); const imgLink = document.createElement("a");
imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl); imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl);
imgLink.title = ifcUrl; imgLink.title = ifcUrl;
imgLink.target = "_blank"; imgLink.target = "_blank";
imgLink.rel = "noopener noreferrer"; imgLink.rel = "noopener noreferrer";
imgLink.appendChild(img); imgLink.appendChild(img);
var code = document.createElement("code"); const code = document.createElement("code");
code.textContent = ifcUrl; code.textContent = ifcUrl;
var urlLink = document.createElement("a"); const urlLink = document.createElement("a");
urlLink.href = ifcUrl; urlLink.href = ifcUrl;
urlLink.appendChild(code); urlLink.appendChild(code);
var caption = document.createElement("figcaption"); const caption = document.createElement("figcaption");
caption.appendChild(urlLink); caption.appendChild(urlLink);
var figure = document.createElement("figure"); const figure = document.createElement("figure");
figure.className = "ifcurl-preview"; figure.className = "ifcurl-preview";
figure.appendChild(imgLink); figure.appendChild(imgLink);
figure.appendChild(caption); figure.appendChild(caption);
@ -69,19 +60,19 @@
} }
function init() { function init() {
var host = window.location.host; const host = window.location.host;
replaceIfcAnchors(); replaceIfcAnchors();
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Case 1: single file view — permalink button in .file-header-right. // Case 1: single file view — permalink button in .file-header-right.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
var btnGroup = document.querySelector(".file-header-right .ui.buttons"); const btnGroup = document.querySelector(".file-header-right .ui.buttons");
if (btnGroup) { if (btnGroup) {
// Permalink href or current URL when already at a commit. // Permalink href or current URL when already at a commit.
var a = document.querySelector('a[href*="/src/commit/"]'); const a = document.querySelector('a[href*="/src/commit/"]');
var href = a ? a.getAttribute("href") : window.location.pathname; const href = a ? a.getAttribute("href") : window.location.pathname;
var info = parseCommitHref(href); const info = parseCommitHref(href);
if (info && info.treePath.toLowerCase().endsWith(".ifc")) { if (info && info.treePath.toLowerCase().endsWith(".ifc")) {
btnGroup.appendChild(makeViewerLink(info, "View in 3D")); btnGroup.appendChild(makeViewerLink(info, "View in 3D"));
} }
@ -93,48 +84,48 @@
// each .ifc file in the diff. Requires Nginx to proxy /render_diff to // each .ifc file in the diff. Requires Nginx to proxy /render_diff to
// the ifcurl preview service (see forgejo/README.md). // the ifcurl preview service (see forgejo/README.md).
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
var prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/); const prMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/pulls\/(\d+)\/files$/);
if (prMatch) { if (prMatch) {
var prRepoPath = prMatch[1]; const prRepoPath = prMatch[1];
var prNum = prMatch[2]; const prNum = prMatch[2];
fetch("/api/v1/repos/" + prRepoPath + "/pulls/" + prNum) fetch("/api/v1/repos/" + prRepoPath + "/pulls/" + prNum)
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); }) .then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function(pr) { .then(function(pr) {
var baseSha = pr.base && pr.base.sha; const baseSha = pr.base && pr.base.sha;
var headSha = pr.head && pr.head.sha; const headSha = pr.head && pr.head.sha;
if (!baseSha || !headSha) return; if (!baseSha || !headSha) return;
var origin = window.location.origin; const origin = window.location.origin;
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) { document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
var info = parseCommitHref(a.getAttribute("href")); const info = parseCommitHref(a.getAttribute("href"));
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return; if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
if (info.hash !== headSha) return; if (info.hash !== headSha) return;
var baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha const baseIfc = "ifc://" + host + "/" + prRepoPath + "@" + baseSha
+ "?path=" + encodeURIComponent(info.treePath); + "?path=" + encodeURIComponent(info.treePath);
var headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha const headIfc = "ifc://" + host + "/" + prRepoPath + "@" + headSha
+ "?path=" + encodeURIComponent(info.treePath); + "?path=" + encodeURIComponent(info.treePath);
var img = document.createElement("img"); const img = document.createElement("img");
img.src = origin + "/render_diff" img.src = origin + "/render_diff"
+ "?base=" + encodeURIComponent(baseIfc) + "?base=" + encodeURIComponent(baseIfc)
+ "&head=" + encodeURIComponent(headIfc); + "&head=" + encodeURIComponent(headIfc);
img.style.cssText = "max-width:100%;display:block"; img.style.cssText = "max-width:100%;display:block";
img.alt = info.treePath; img.alt = info.treePath;
var imgLink = document.createElement("a"); const imgLink = document.createElement("a");
imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(headIfc); imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(headIfc);
imgLink.target = "_blank"; imgLink.target = "_blank";
imgLink.rel = "noopener noreferrer"; imgLink.rel = "noopener noreferrer";
imgLink.appendChild(img); imgLink.appendChild(img);
var code = document.createElement("code"); const code = document.createElement("code");
code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px"; code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
code.textContent = headIfc; code.textContent = headIfc;
var urlLink = document.createElement("a"); const urlLink = document.createElement("a");
urlLink.href = headIfc; urlLink.href = headIfc;
urlLink.appendChild(code); urlLink.appendChild(code);
var container = document.createElement("div"); const container = document.createElement("div");
container.style.cssText = "margin:8px 0"; container.style.cssText = "margin:8px 0";
container.appendChild(imgLink); container.appendChild(imgLink);
container.appendChild(urlLink); container.appendChild(urlLink);
var fileBox = a.closest(".diff-file-box"); const fileBox = a.closest(".diff-file-box");
var header = fileBox && fileBox.querySelector(".file-header"); const header = fileBox && fileBox.querySelector(".file-header");
if (header) { if (header) {
header.insertAdjacentElement("afterend", container); header.insertAdjacentElement("afterend", container);
} else { } else {
@ -151,31 +142,31 @@
// preview for added/deleted files) below the header of each .ifc file // preview for added/deleted files) below the header of each .ifc file
// in the diff. // in the diff.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
var commitMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i); const commitMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i);
if (commitMatch) { if (commitMatch) {
var repoPath = commitMatch[1]; const repoPath = commitMatch[1];
var headSha = commitMatch[2]; const headSha = commitMatch[2];
var origin = window.location.origin; const origin = window.location.origin;
// Extract parent SHA from the "parent" commit link at the top of the page. // Extract parent SHA from the "parent" commit link at the top of the page.
var parentSha = null; let parentSha = null;
var parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']"); const parentLink = document.querySelector("a.primary.sha.label[href*='/commit/']");
if (parentLink) { if (parentLink) {
var pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i); const pm = parentLink.getAttribute("href").match(/\/commit\/([0-9a-f]{40})$/i);
if (pm) parentSha = pm[1]; if (pm) parentSha = pm[1];
} }
document.querySelectorAll(".diff-file-box").forEach(function(box) { document.querySelectorAll(".diff-file-box").forEach(function(box) {
var newFile = box.getAttribute("data-new-filename") || ""; const newFile = box.getAttribute("data-new-filename") || "";
var oldFile = box.getAttribute("data-old-filename") || ""; const oldFile = box.getAttribute("data-old-filename") || "";
var newIsIfc = newFile.toLowerCase().endsWith(".ifc"); const newIsIfc = newFile.toLowerCase().endsWith(".ifc");
var oldIsIfc = oldFile.toLowerCase().endsWith(".ifc"); const oldIsIfc = oldFile.toLowerCase().endsWith(".ifc");
var ifcPath, imgSrc, viewerIfc; let ifcPath, imgSrc, viewerIfc;
if (newIsIfc && oldIsIfc && newFile === oldFile && parentSha) { if (newIsIfc && oldIsIfc && newFile === oldFile && parentSha) {
// Modified: show colour-coded diff // Modified: show colour-coded diff
ifcPath = newFile; ifcPath = newFile;
var baseIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath); const baseIfc = "ifc://" + host + "/" + repoPath + "@" + parentSha + "?path=" + encodeURIComponent(ifcPath);
viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath); viewerIfc = "ifc://" + host + "/" + repoPath + "@" + headSha + "?path=" + encodeURIComponent(ifcPath);
imgSrc = origin + "/render_diff?base=" + encodeURIComponent(baseIfc) + "&head=" + encodeURIComponent(viewerIfc); imgSrc = origin + "/render_diff?base=" + encodeURIComponent(baseIfc) + "&head=" + encodeURIComponent(viewerIfc);
} else if (newIsIfc) { } else if (newIsIfc) {
@ -192,26 +183,26 @@
return; return;
} }
var img = document.createElement("img"); const img = document.createElement("img");
img.src = imgSrc; img.src = imgSrc;
img.style.cssText = "max-width:100%;display:block"; img.style.cssText = "max-width:100%;display:block";
img.alt = ifcPath; img.alt = ifcPath;
var imgLink = document.createElement("a"); const imgLink = document.createElement("a");
imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(viewerIfc); imgLink.href = VIEWER_BASE + "?url=" + encodeURIComponent(viewerIfc);
imgLink.target = "_blank"; imgLink.target = "_blank";
imgLink.rel = "noopener noreferrer"; imgLink.rel = "noopener noreferrer";
imgLink.appendChild(img); imgLink.appendChild(img);
var code = document.createElement("code"); const code = document.createElement("code");
code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px"; code.style.cssText = "display:block;font-size:0.85em;word-break:break-all;margin-top:4px";
code.textContent = viewerIfc; code.textContent = viewerIfc;
var urlLink = document.createElement("a"); const urlLink = document.createElement("a");
urlLink.href = viewerIfc; urlLink.href = viewerIfc;
urlLink.appendChild(code); urlLink.appendChild(code);
var container = document.createElement("div"); const container = document.createElement("div");
container.style.cssText = "margin:8px 0"; container.style.cssText = "margin:8px 0";
container.appendChild(imgLink); container.appendChild(imgLink);
container.appendChild(urlLink); container.appendChild(urlLink);
var header = box.querySelector(".diff-file-header"); const header = box.querySelector(".diff-file-header");
if (header) header.insertAdjacentElement("afterend", container); if (header) header.insertAdjacentElement("afterend", container);
}); });
return; return;
@ -222,17 +213,13 @@
// browse-file link (/src/commit/{hash}/{treepath}). // browse-file link (/src/commit/{hash}/{treepath}).
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) { document.querySelectorAll('a[href*="/src/commit/"]').forEach(function(a) {
var info = parseCommitHref(a.getAttribute("href")); const info = parseCommitHref(a.getAttribute("href"));
if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return; if (!info || !info.treePath.toLowerCase().endsWith(".ifc")) return;
var btn = makeViewerLink(info, "3D"); const btn = makeViewerLink(info, "3D");
btn.style.marginLeft = "4px"; btn.style.marginLeft = "4px";
a.insertAdjacentElement("afterend", btn); a.insertAdjacentElement("afterend", btn);
}); });
} }
if (document.readyState === "loading") { // ES modules are deferred by default, so the DOM is always ready here.
document.addEventListener("DOMContentLoaded", init);
} else {
init(); init();
}
})();

View file

@ -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<number> }.
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 (740 hex chars).
export function isCommitHash(ref) {
return /^[0-9a-f]{7,40}$/.test(ref);
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { THREE, OBC, JSZip } from "/assets/viewer-deps.js"; import { THREE, OBC, JSZip } from "/assets/viewer-deps.js";
import { parseIfcUrl, toRawUrl, buildIfcUrl as _buildIfcUrl } from "./viewer-url.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 params = new URLSearchParams(window.location.search);
const ifcUrl = params.get("url") ?? ""; 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. // server-side via GET /select, then applied by GUID in the viewer.
// Requires the ifcurl service to be running. // 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. // Apply a fragment material style to the given items via FragmentsManager.
async function applyFragmentStyle(components, items, color, opacity) { async function applyFragmentStyle(components, items, color, opacity) {
@ -982,10 +966,6 @@ async function populateRefList(host, repoSuffix) {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Commit-pin indicator helpers. // Commit-pin indicator helpers.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
function isCommitHash(ref) {
return /^[0-9a-f]{7,40}$/.test(ref);
}
async function fetchBranchForCommit(host, repoSuffix, hash) { async function fetchBranchForCommit(host, repoSuffix, hash) {
const proto = host.startsWith("localhost") ? "http" : "https"; const proto = host.startsWith("localhost") ? "http" : "https";
const apiBase = `${proto}://${host}/api/v1/repos/${repoSuffix}`; const apiBase = `${proto}://${host}/api/v1/repos/${repoSuffix}`;

View file

@ -3,7 +3,8 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node build.js" "build": "node build.js",
"test": "node --test tests/*.test.js"
}, },
"dependencies": { "dependencies": {
"@thatopen/components": "3.4.2", "@thatopen/components": "3.4.2",

View file

@ -1 +1 @@
<script src="/assets/ifcurl.js"></script> <script type="module" src="/assets/ifcurl.js"></script>

View file

@ -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}`);
});
});

View file

@ -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);
});
});