From 0ba2c746feb05c02d6eb3413509cfc97211452fc Mon Sep 17 00:00:00 2001 From: gerteck Date: Fri, 12 Dec 2025 03:41:09 +0800 Subject: [PATCH 1/6] Fix navigation anchor parsing --- packages/core/src/html/siteAndPageNavProcessor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/html/siteAndPageNavProcessor.ts b/packages/core/src/html/siteAndPageNavProcessor.ts index fcd39d8984..beaa78f950 100644 --- a/packages/core/src/html/siteAndPageNavProcessor.ts +++ b/packages/core/src/html/siteAndPageNavProcessor.ts @@ -104,7 +104,7 @@ export function renderSiteNav(node: MbNode) { $(ulElem).children('li').each((_i2, liElem) => { const nestedLists = $(liElem).children('ul'); - const nestedAnchors = $(liElem).children('a'); + const nestedAnchors = $(liElem).find('a'); if (nestedLists.length === 0 && nestedAnchors.length === 0) { $(liElem).addClass(customListItemClasses); return; From af53c190d207728cbd399ed48b9648194ed17b8d Mon Sep 17 00:00:00 2001 From: gerteck Date: Sun, 14 Dec 2025 17:45:47 +0800 Subject: [PATCH 2/6] Fix navbar highlighting logic Use best match instead of first match --- packages/vue-components/src/Navbar.vue | 90 +++++++++++++++----------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/vue-components/src/Navbar.vue b/packages/vue-components/src/Navbar.vue index 76090244d9..006ebd62bb 100644 --- a/packages/vue-components/src/Navbar.vue +++ b/packages/vue-components/src/Navbar.vue @@ -155,64 +155,78 @@ export default { const defHlMode = this.defaultHighlightOn; const navLis = []; this.$el.querySelectorAll('.navbar-nav').forEach(nav => navLis.push(...Array.from(nav.children))); - // attempt an exact match first + + // Each li element in navbar grouped with all its own and children links + const allNavLinkGroups = []; for (let i = 0; i < navLis.length; i += 1) { const li = navLis[i]; const standardLinks = [li]; const navLinks = Array.from(li.querySelectorAll('a.nav-link')); const dropdownLinks = Array.from(li.querySelectorAll('a.dropdown-item')); - const allNavLinks = standardLinks.concat(navLinks).concat(dropdownLinks).filter(a => a.href); - for (let j = 0; j < allNavLinks.length; j += 1) { - const a = allNavLinks[j]; + const linksInLi = standardLinks.concat(navLinks).concat(dropdownLinks).filter(a => a.href); + + allNavLinkGroups.push({ + li, + links: linksInLi, + dropdownLinks + }); + } + + // attempt an exact match first, highlight parent li if found + for (const group of allNavLinkGroups) { + for (const a of group.links) { const hlMode = a.getAttribute('highlight-on') || defHlMode; - if (hlMode === 'none') { - // eslint-disable-next-line no-continue - continue; - } - // terminate early on an exact match - if (this.isExact(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); - return; + if (hlMode !== 'none' && this.isExact(url, a.href)) { + group.li.classList.add('current'); + this.addClassIfDropdown(group.dropdownLinks, a, group.li); + return; } } } - // fallback to user preference, otherwise - for (let i = 0; i < navLis.length; i += 1) { - const li = navLis[i]; - const standardLinks = [li]; - const navLinks = Array.from(li.querySelectorAll('a.nav-link')); - const dropdownLinks = Array.from(li.querySelectorAll('a.dropdown-item')); - const allNavLinks = standardLinks.concat(navLinks).concat(dropdownLinks).filter(a => a.href); - for (let j = 0; j < allNavLinks.length; j += 1) { - const a = allNavLinks[j]; + + // Else, find Best Match (Longest path length, most specific) + // Strategies: 'sibling-or-child' (default), 'sibling', 'child' + let bestMatch = null; + let maxPathLength = -1; + + for (const group of allNavLinkGroups) { + for (const a of group.links) { const hlMode = a.getAttribute('highlight-on') || defHlMode; if (hlMode === 'none') { - // eslint-disable-next-line no-continue continue; } - // Ignores invalid navbar highlight rule + + let isMatch = false; if (hlMode === 'sibling-or-child') { - if (this.isSibling(url, a.href) || this.isChild(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); - return; - } + isMatch = this.isSibling(url, a.href) || this.isChild(url, a.href); } else if (hlMode === 'sibling') { - if (this.isSibling(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); - return; - } + isMatch = this.isSibling(url, a.href); } else if (hlMode === 'child') { - if (this.isChild(url, a.href)) { - li.classList.add('current'); - this.addClassIfDropdown(dropdownLinks, a, li); - return; + isMatch = this.isChild(url, a.href); + } + + // Find the match that is most specific + if (isMatch) { + const linkParts = this.splitUrl(a.href); + const pathLength = linkParts.length; + + if (pathLength > maxPathLength) { + maxPathLength = pathLength; + bestMatch = { + li: group.li, + a: a, + dropdownLinks: group.dropdownLinks + }; } } } } + + // Apply the highlight to the best match found (if any) + if (bestMatch) { + bestMatch.li.classList.add('current'); + this.addClassIfDropdown(bestMatch.dropdownLinks, bestMatch.a, bestMatch.li); + } }, toggleLowerNavbar() { if (this.$refs.lowerNavbar.childElementCount > 0) { From 5170c1df85b9c85e70d895d8b8661d6eb91b01ef Mon Sep 17 00:00:00 2001 From: gerteck Date: Sun, 14 Dec 2025 17:54:51 +0800 Subject: [PATCH 3/6] Fix lints --- packages/vue-components/src/Navbar.vue | 74 +++++++++++++------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/vue-components/src/Navbar.vue b/packages/vue-components/src/Navbar.vue index 006ebd62bb..5756201e07 100644 --- a/packages/vue-components/src/Navbar.vue +++ b/packages/vue-components/src/Navbar.vue @@ -155,7 +155,7 @@ export default { const defHlMode = this.defaultHighlightOn; const navLis = []; this.$el.querySelectorAll('.navbar-nav').forEach(nav => navLis.push(...Array.from(nav.children))); - + // Each li element in navbar grouped with all its own and children links const allNavLinkGroups = []; for (let i = 0; i < navLis.length; i += 1) { @@ -164,68 +164,68 @@ export default { const navLinks = Array.from(li.querySelectorAll('a.nav-link')); const dropdownLinks = Array.from(li.querySelectorAll('a.dropdown-item')); const linksInLi = standardLinks.concat(navLinks).concat(dropdownLinks).filter(a => a.href); - + allNavLinkGroups.push({ li, - links: linksInLi, - dropdownLinks + links: linksInLi, + dropdownLinks, }); } - // attempt an exact match first, highlight parent li if found - for (const group of allNavLinkGroups) { - for (const a of group.links) { + // 1: Check for Exact Match,return immediately if found + for (let i = 0; i < allNavLinkGroups.length; i += 1) { + const group = allNavLinkGroups[i]; + for (let j = 0; j < group.links.length; j += 1) { + const a = group.links[j]; const hlMode = a.getAttribute('highlight-on') || defHlMode; if (hlMode !== 'none' && this.isExact(url, a.href)) { - group.li.classList.add('current'); - this.addClassIfDropdown(group.dropdownLinks, a, group.li); - return; + group.li.classList.add('current'); + this.addClassIfDropdown(group.dropdownLinks, a, group.li); + return; } } } - // Else, find Best Match (Longest path length, most specific) + // 2: Find Best Fuzzy Match // Strategies: 'sibling-or-child' (default), 'sibling', 'child' + // Tie-breaker: Longest path length (most specific match) let bestMatch = null; let maxPathLength = -1; - for (const group of allNavLinkGroups) { - for (const a of group.links) { + allNavLinkGroups.forEach((group) => { + group.links.forEach((a) => { const hlMode = a.getAttribute('highlight-on') || defHlMode; - if (hlMode === 'none') { - continue; - } - - let isMatch = false; - if (hlMode === 'sibling-or-child') { - isMatch = this.isSibling(url, a.href) || this.isChild(url, a.href); - } else if (hlMode === 'sibling') { - isMatch = this.isSibling(url, a.href); - } else if (hlMode === 'child') { - isMatch = this.isChild(url, a.href); - } + if (hlMode !== 'none') { + let isMatch = false; + if (hlMode === 'sibling-or-child') { + isMatch = this.isSibling(url, a.href) || this.isChild(url, a.href); + } else if (hlMode === 'sibling') { + isMatch = this.isSibling(url, a.href); + } else if (hlMode === 'child') { + isMatch = this.isChild(url, a.href); + } - // Find the match that is most specific - if (isMatch) { - const linkParts = this.splitUrl(a.href); - const pathLength = linkParts.length; + if (isMatch) { + const linkParts = this.splitUrl(a.href); + const pathLength = linkParts.length; - if (pathLength > maxPathLength) { + if (pathLength > maxPathLength) { maxPathLength = pathLength; bestMatch = { - li: group.li, - a: a, - dropdownLinks: group.dropdownLinks + li: group.li, + a, + dropdownLinks: group.dropdownLinks, }; + } } } - } - } + }); + }); // Apply the highlight to the best match found (if any) if (bestMatch) { - bestMatch.li.classList.add('current'); - this.addClassIfDropdown(bestMatch.dropdownLinks, bestMatch.a, bestMatch.li); + bestMatch.li.classList.add('current'); + this.addClassIfDropdown(bestMatch.dropdownLinks, bestMatch.a, bestMatch.li); } }, toggleLowerNavbar() { From 097d732713cd250aff668b0111165486bddf113c Mon Sep 17 00:00:00 2001 From: gerteck Date: Sun, 14 Dec 2025 17:57:44 +0800 Subject: [PATCH 4/6] Add navbar highlight tests --- .../src/__tests__/Navbar.spec.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/vue-components/src/__tests__/Navbar.spec.js b/packages/vue-components/src/__tests__/Navbar.spec.js index 4953a844ba..a220634a7c 100644 --- a/packages/vue-components/src/__tests__/Navbar.spec.js +++ b/packages/vue-components/src/__tests__/Navbar.spec.js @@ -157,3 +157,45 @@ describe('Mobile nav buttons test:', () => { wrapper.unmount(); }); }); + +describe('Navbar Highlight Logic', () => { + test('correctly highlights the deepest matching link (Best Match)', async () => { + const deepUrl = 'http://localhost/lessons/trail/recordingFolderHistory/index.html'; + const TEST_NAVBAR_CONTENT = ` +
  • Home
  • +
  • Lessons
  • +
  • About
  • + `; + + // Test highlightLink(url) method on the component. + // Mount it, then call highlightLink manually with test URL. + const wrapper = mount(Navbar, { + slots: { + brand: NAVBAR_BRAND, + default: TEST_NAVBAR_CONTENT, + }, + global: { + stubs: DEFAULT_STUBS, + }, + }); + + // Clear any initial highlighting from mounted() + wrapper.findAll('li').forEach(li => li.element.classList.remove('current')); + + // Manually trigger highlight with the problematic URL + wrapper.vm.highlightLink(deepUrl); + await wrapper.vm.$nextTick(); + + const homeLink = wrapper.find('a[href="/index.html"]').element; + const lessonsLink = wrapper.find('a[href="/lessons/index.html"]').element; + + const homeLi = homeLink.closest('li'); + const lessonsLi = lessonsLink.closest('li'); + + // Expectation: Lessons should be current because it is deeper / more specific + expect(lessonsLi.classList.contains('current')).toBe(true); + + // Expectation: Home should NOT be current (it is a match, but a worse one) + expect(homeLi.classList.contains('current')).toBe(false); + }); +}); From c252c8abae2c21d367d0f038fceb6043444ed27a Mon Sep 17 00:00:00 2001 From: gerteck Date: Sun, 14 Dec 2025 18:04:12 +0800 Subject: [PATCH 5/6] Update testcase --- .../src/__tests__/Navbar.spec.js | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/vue-components/src/__tests__/Navbar.spec.js b/packages/vue-components/src/__tests__/Navbar.spec.js index a220634a7c..90e3a5a1c4 100644 --- a/packages/vue-components/src/__tests__/Navbar.spec.js +++ b/packages/vue-components/src/__tests__/Navbar.spec.js @@ -131,31 +131,31 @@ describe('Mobile nav buttons test:', () => { ['', PAGE_NAV_BUTTON, PageNavButton, 'page-nav'], ['', PAGE_NAV_BUTTON, PageNavButton, 'mb-page-nav'], ])('Nav buttons set the portal name accordingly if the respective selectors are not found.', - async (navContent, lowerNavbarSlot, NavComponent, portalName) => { - document.getElementById('navContentTarget').innerHTML = navContent; - - const wrapper = mount(Navbar, { - attachTo: '#navbarTarget', - slots: { - brand: NAVBAR_BRAND, - default: NAVBAR_CONTENT, - 'lower-navbar': lowerNavbarSlot, - }, - global: { - stubs: { - ...DEFAULT_STUBS, - 'overlay': true, - }, - }, - }); - - const navComponent = wrapper.findComponent(NavComponent); - expect(navComponent.exists()).toBe(true); - - expect(navComponent.vm.portalName).toBe(portalName); - - wrapper.unmount(); - }); + async (navContent, lowerNavbarSlot, NavComponent, portalName) => { + document.getElementById('navContentTarget').innerHTML = navContent; + + const wrapper = mount(Navbar, { + attachTo: '#navbarTarget', + slots: { + brand: NAVBAR_BRAND, + default: NAVBAR_CONTENT, + 'lower-navbar': lowerNavbarSlot, + }, + global: { + stubs: { + ...DEFAULT_STUBS, + 'overlay': true, + }, + }, + }); + + const navComponent = wrapper.findComponent(NavComponent); + expect(navComponent.exists()).toBe(true); + + expect(navComponent.vm.portalName).toBe(portalName); + + wrapper.unmount(); + }); }); describe('Navbar Highlight Logic', () => { @@ -164,6 +164,7 @@ describe('Navbar Highlight Logic', () => { const TEST_NAVBAR_CONTENT = `
  • Home
  • Lessons
  • +
  • Trail
  • About
  • `; @@ -188,14 +189,17 @@ describe('Navbar Highlight Logic', () => { const homeLink = wrapper.find('a[href="/index.html"]').element; const lessonsLink = wrapper.find('a[href="/lessons/index.html"]').element; + const trailLink = wrapper.find('a[href="/lessons/trail/index.html"]').element; const homeLi = homeLink.closest('li'); const lessonsLi = lessonsLink.closest('li'); + const trailLi = trailLink.closest('li'); - // Expectation: Lessons should be current because it is deeper / more specific - expect(lessonsLi.classList.contains('current')).toBe(true); + // Expectation: Trail should be current because it is the deepest matching link (depth 3 vs 2 vs 1) + expect(trailLi.classList.contains('current')).toBe(true); - // Expectation: Home should NOT be current (it is a match, but a worse one) + // Expectation: Shallower matches should NOT be current + expect(lessonsLi.classList.contains('current')).toBe(false); expect(homeLi.classList.contains('current')).toBe(false); }); }); From 6e88cc916b4ce5b275e3e1303063128861aec2a5 Mon Sep 17 00:00:00 2001 From: gerteck Date: Sun, 14 Dec 2025 19:27:49 +0800 Subject: [PATCH 6/6] Fix linting errors --- .../src/__tests__/Navbar.spec.js | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/vue-components/src/__tests__/Navbar.spec.js b/packages/vue-components/src/__tests__/Navbar.spec.js index 90e3a5a1c4..dead88df76 100644 --- a/packages/vue-components/src/__tests__/Navbar.spec.js +++ b/packages/vue-components/src/__tests__/Navbar.spec.js @@ -131,31 +131,31 @@ describe('Mobile nav buttons test:', () => { ['', PAGE_NAV_BUTTON, PageNavButton, 'page-nav'], ['', PAGE_NAV_BUTTON, PageNavButton, 'mb-page-nav'], ])('Nav buttons set the portal name accordingly if the respective selectors are not found.', - async (navContent, lowerNavbarSlot, NavComponent, portalName) => { - document.getElementById('navContentTarget').innerHTML = navContent; - - const wrapper = mount(Navbar, { - attachTo: '#navbarTarget', - slots: { - brand: NAVBAR_BRAND, - default: NAVBAR_CONTENT, - 'lower-navbar': lowerNavbarSlot, - }, - global: { - stubs: { - ...DEFAULT_STUBS, - 'overlay': true, - }, - }, - }); - - const navComponent = wrapper.findComponent(NavComponent); - expect(navComponent.exists()).toBe(true); - - expect(navComponent.vm.portalName).toBe(portalName); - - wrapper.unmount(); - }); + async (navContent, lowerNavbarSlot, NavComponent, portalName) => { + document.getElementById('navContentTarget').innerHTML = navContent; + + const wrapper = mount(Navbar, { + attachTo: '#navbarTarget', + slots: { + brand: NAVBAR_BRAND, + default: NAVBAR_CONTENT, + 'lower-navbar': lowerNavbarSlot, + }, + global: { + stubs: { + ...DEFAULT_STUBS, + 'overlay': true, + }, + }, + }); + + const navComponent = wrapper.findComponent(NavComponent); + expect(navComponent.exists()).toBe(true); + + expect(navComponent.vm.portalName).toBe(portalName); + + wrapper.unmount(); + }); }); describe('Navbar Highlight Logic', () => {