diff --git a/docs-site/extensions/antora-static-nav/lib/index.js b/docs-site/extensions/antora-static-nav/lib/index.js
index 67ff93c58..32ac8e4bc 100644
--- a/docs-site/extensions/antora-static-nav/lib/index.js
+++ b/docs-site/extensions/antora-static-nav/lib/index.js
@@ -87,6 +87,44 @@ function cleanNav (nav) {
.replace(/(
]+class=["'][^"']*?)\s+is-active\b/g, '$1')
}
+// ---------------------------------------------------------------------------
+// Href/src rebasing
+// ---------------------------------------------------------------------------
+
+const URL_REBASE_RX = /(\s(?:href|src)=)(["'])([^"']*)\2/g
+const ABSOLUTE_OR_FRAGMENT_RX = /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#|data:|mailto:)/i
+
+/**
+ * Rewrite a single href/src value so it is relative to the component@version
+ * root rather than to the sample page's directory.
+ *
+ * Antora emits nav URLs as page-relative paths. When the captured nav is
+ * reused on pages at a different depth, these page-relative paths break.
+ * Normalising to component-root-relative makes the URLs portable; the loader
+ * then prepends each page's own back-to-root prefix at injection time.
+ */
+function rebaseUrl (value, sampleDirRelToRoot) {
+ if (!value) return value
+ if (ABSOLUTE_OR_FRAGMENT_RX.test(value)) return value
+ const base = sampleDirRelToRoot ? '/' + sampleDirRelToRoot + '/' : '/'
+ const resolved = path.posix.normalize(base + value)
+ return resolved.replace(/^\/+/, '')
+}
+
+/**
+ * Rebase every href/src in the nav HTML to be component-root-relative.
+ * `sampleDirRelToRoot` is the sample page's directory expressed relative to
+ * the component@version output root (e.g. "" for a root-level page,
+ * "source" for source/foo.html, "module/sub" for nested pages).
+ */
+function rebaseNav (navHtml, sampleDirRelToRoot) {
+ const norm = sampleDirRelToRoot && sampleDirRelToRoot !== '.' ? sampleDirRelToRoot : ''
+ return navHtml.replace(URL_REBASE_RX, (m, attr, q, val) => {
+ const next = rebaseUrl(val, norm)
+ return next === val ? m : `${attr}${q}${next}${q}`
+ })
+}
+
// ---------------------------------------------------------------------------
// Output builders
// ---------------------------------------------------------------------------
@@ -94,19 +132,27 @@ function cleanNav (nav) {
/**
* Build the content of _static_nav.js.
*
- * Injects the nav synchronously via currentScript.parentNode.insertBefore —
- * no document.write(), no fetch(). JSON.stringify handles all HTML escaping.
+ * Exposes window.__antoraStaticNav(prefix). The per-page inline script calls
+ * it with the page's relative path back to the component@version root, so a
+ * single shared nav can serve pages at any depth. The injected nav is placed
+ * just before the calling script, where the original nav block lived.
*/
function buildNavJs (navHtml) {
const navJson = JSON.stringify(navHtml)
return (
- '(function(){' +
- 'var s=document.currentScript||document.scripts[document.scripts.length-1];' +
+ 'window.__antoraStaticNav=function(prefix){' +
+ 'var s=document.currentScript;' +
+ 'if(!s){var ss=document.scripts;s=ss[ss.length-1];}' +
+ 'if(!s)return;' +
+ `var html=${navJson};` +
+ 'if(prefix){html=html.replace(/(\\s(?:href|src)=)(["\'])([^"\']*)\\2/g,function(m,a,q,v){' +
+ 'if(/^(?:[a-z][a-z0-9+.-]*:|\\/\\/|\\/|#|data:|mailto:)/i.test(v))return m;' +
+ 'return a+q+prefix+v+q;});}' +
'var t=document.createElement("div");' +
- `t.innerHTML=${navJson};` +
+ 't.innerHTML=html;' +
'var n=t.firstChild;' +
'if(n)s.parentNode.insertBefore(n,s);' +
- '})();\n'
+ '};\n'
)
}
@@ -114,12 +160,15 @@ function buildNavJs (navHtml) {
* Build the HTML fragment that replaces a page's embedded nav block.
*
* Two