Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 85 additions & 17 deletions docs-site/extensions/antora-static-nav/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,39 +87,88 @@ function cleanNav (nav) {
.replace(/(<li\b[^>]+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
// ---------------------------------------------------------------------------

/**
* 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'
)
}

/**
* Build the HTML fragment that replaces a page's embedded nav block.
*
* Two <script> elements:
* 1. Synchronous loader — injects _static_nav.js before site.js runs
* 2. Tiny inline — re-applies is-current-page to this page's nav entry
* 1. Synchronous loader — fetches _static_nav.js (defines the global)
* 2. Tiny inline — invokes the global with this page's back-to-root prefix
* and re-applies is-current-page to this page's nav entry
*/
function buildReplacement (navRel, pageBasename) {
function buildReplacement (navRel, pageBasename, prefix) {
const fileJson = JSON.stringify(pageBasename)
const prefixJson = JSON.stringify(prefix || '')
const currentPageJs =
`window.__antoraStaticNav&&window.__antoraStaticNav(${prefixJson});` +
`!function(){var f=${fileJson},` +
"ls=document.querySelectorAll('.nav-link[href]');" +
'for(var i=0;i<ls.length;i++){' +
Expand All @@ -134,7 +183,16 @@ function buildReplacement (navRel, pageBasename) {
// Extension entry point
// ---------------------------------------------------------------------------

module.exports = { findNavBlock, cleanNav, buildNavJs, buildReplacement, MARKER, SHARED_NAV_NAME }
module.exports = {
findNavBlock,
cleanNav,
rebaseNav,
rebaseUrl,
buildNavJs,
buildReplacement,
MARKER,
SHARED_NAV_NAME,
}

module.exports.register = function register ({ config = {} }) {
const threshold = config.threshold ?? 50_000
Expand All @@ -160,13 +218,20 @@ module.exports.register = function register ({ config = {} }) {
let totalSaved = 0

for (const [key, pages] of groups) {
const { component, version } = pages[0].src
const componentRoot = path.posix.join(component, version)

// Sample up to 10 pages to find the first one with a large-enough nav.
// Capture the sample's directory so we can rebase nav URLs to be
// relative to the component@version root.
let navHtml = null
for (const page of pages.slice(0, 10)) {
const html = page.contents.toString('utf8')
const block = findNavBlock(html)
if (block && block.text.length >= threshold) {
navHtml = cleanNav(block.text)
const sampleDir = page.out.dirname.replace(/\\/g, '/')
const sampleDirRelToRoot = path.posix.relative(componentRoot, sampleDir)
navHtml = rebaseNav(cleanNav(block.text), sampleDirRelToRoot)
break
}
}
Expand All @@ -179,8 +244,7 @@ module.exports.register = function register ({ config = {} }) {
'static-nav: extracting shared nav'
)

const { component, version } = pages[0].src
const navJsOutPath = path.join(component, version, SHARED_NAV_NAME).replace(/\\/g, '/')
const navJsOutPath = path.posix.join(componentRoot, SHARED_NAV_NAME)

const navJsContent = buildNavJs(navHtml)
siteCatalog.addFile({
Expand Down Expand Up @@ -208,11 +272,15 @@ module.exports.register = function register ({ config = {} }) {
continue
}

const navRel = path
.relative(page.out.dirname, navJsOutPath)
.replace(/\\/g, '/')
const pageDir = page.out.dirname.replace(/\\/g, '/')
const navRel = path.posix.relative(pageDir, navJsOutPath)
// Per-page prefix taking nav URLs from component-root-relative back
// to the URL space of this page (e.g. "" for root pages, "../" for
// pages one level deep, "../../" for two levels, etc.).
const backToRoot = path.posix.relative(pageDir, componentRoot)
const prefix = backToRoot === '' ? '' : backToRoot + '/'

const replacement = buildReplacement(navRel, page.out.basename)
const replacement = buildReplacement(navRel, page.out.basename, prefix)
const newHtml = html.slice(0, block.start) + replacement + html.slice(block.end)
page.contents = Buffer.from(newHtml, 'utf8')

Expand Down
89 changes: 89 additions & 0 deletions docs-site/extensions/antora-static-nav/test/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,93 @@ describe('register integration', () => {
const rewritten = page.contents.toString('utf8')
assert.ok(rewritten.includes('"vkSpecialFn.html"'), 'page filename in inline script')
})

// Regression for the /source/source/ duplication bug. When the nav is
// sampled from one page and reused on pages at a different depth, the
// shared nav HTML must use component-root-relative URLs and each page's
// loader call must pass the right back-to-root prefix so the browser
// resolves the link against the page correctly.
it('serves correct links across pages at different depths', () => {
// Build a realistic nav with hrefs pointing into source/.
const navWithRealLinks =
'<div id="split-0"><ul class="nav-list">' +
'<li class="nav-item"><a class="nav-link" href="source/VK_AMDX_dense_geometry_format.html">Ext</a></li>' +
'<li class="nav-item"><a class="nav-link" href="index.html">Home</a></li>' +
'<li>' + 'x'.repeat(60_000) + '</li>' +
'</ul></div>'

// Two pages: one at component root, one nested under source/.
const rootPage = makePage('spec', 'latest', 'ROOT', 'index.html', navWithRealLinks)
const sourcePage = makePage('spec', 'latest', 'ROOT', 'VK_AMDX_other.html', navWithRealLinks)
// Hack: rewrite the source page's out path so it lives in source/.
sourcePage.out.dirname = 'spec/latest/source'
sourcePage.out.path = 'spec/latest/source/VK_AMDX_other.html'

const ctx = makeContext([rootPage, sourcePage])
ctx.fire('pagesComposed')

const navFile = ctx._addedFiles[0]
const navJs = navFile.contents.toString('utf8')

// The root page samples first (Map iteration is insertion order). Sample
// dir is "" so the nav HTML is preserved; the link to source/ext stays
// "source/VK_AMDX_dense_geometry_format.html" in the shared nav.
assert.ok(
navJs.includes('source/VK_AMDX_dense_geometry_format.html'),
'shared nav contains the component-root-relative link'
)

// The root page should call the global with an empty prefix; resolving
// "source/foo.html" from spec/latest/index.html yields the correct URL.
const rootHtml = rootPage.contents.toString('utf8')
assert.ok(
rootHtml.includes('window.__antoraStaticNav&&window.__antoraStaticNav("")'),
`root page expected empty prefix, got: ${rootHtml.match(/__antoraStaticNav\([^)]*\)/g)}`
)

// The source page must call with prefix "../" so the browser resolves
// "../source/VK_AMDX_dense_geometry_format.html" relative to
// spec/latest/source/ → spec/latest/source/VK_AMDX_dense_geometry_format.html
// — NOT spec/latest/source/source/VK_AMDX_dense_geometry_format.html.
const sourceHtml = sourcePage.contents.toString('utf8')
assert.ok(
sourceHtml.includes('window.__antoraStaticNav&&window.__antoraStaticNav("../")'),
`source page expected "../" prefix, got: ${sourceHtml.match(/__antoraStaticNav\([^)]*\)/g)}`
)
})

// Same scenario, but the sample is taken from a nested page first. The
// captured nav has page-relative hrefs; rebasing must promote them to
// component-root-relative so the empty-prefix root page still works.
it('rebases captured nav when the sample page is nested', () => {
const navWithRealLinks =
'<div id="split-0"><ul class="nav-list">' +
// From a page in source/, a sibling extension is just a basename.
'<li class="nav-item"><a class="nav-link" href="VK_AMDX_dense_geometry_format.html">Ext</a></li>' +
// From a page in source/, the component root index is "../index.html".
'<li class="nav-item"><a class="nav-link" href="../index.html">Home</a></li>' +
'<li>' + 'x'.repeat(60_000) + '</li>' +
'</ul></div>'

const sourcePage = makePage('spec', 'latest', 'ROOT', 'VK_AMDX_other.html', navWithRealLinks)
sourcePage.out.dirname = 'spec/latest/source'
sourcePage.out.path = 'spec/latest/source/VK_AMDX_other.html'
const rootPage = makePage('spec', 'latest', 'ROOT', 'index.html', navWithRealLinks)

// Insert source page first so it's the sample.
const ctx = makeContext([sourcePage, rootPage])
ctx.fire('pagesComposed')

const navJs = ctx._addedFiles[0].contents.toString('utf8')
// After rebasing from sample dir "source": the basename href becomes
// "source/VK_AMDX_..." and "../index.html" becomes "index.html".
// Hrefs live inside a JSON-encoded HTML string, so the surrounding
// quotes are escaped (\").
assert.ok(navJs.includes('source/VK_AMDX_dense_geometry_format.html'),
'sibling href was rebased into source/')
assert.ok(navJs.includes('href=\\"index.html\\"'),
'parent-relative href was collapsed to component-root index.html')
assert.ok(!navJs.includes('href=\\"../index.html\\"'),
'rebased nav must not retain the original ../index.html')
})
})
Loading
Loading