diff --git a/.changes/3828400d-3899-4d31-9db7-0b973ce10e43.json b/.changes/3828400d-3899-4d31-9db7-0b973ce10e43.json new file mode 100644 index 00000000000..6d9e8f42c2a --- /dev/null +++ b/.changes/3828400d-3899-4d31-9db7-0b973ce10e43.json @@ -0,0 +1,5 @@ +{ + "id": "3828400d-3899-4d31-9db7-0b973ce10e43", + "type": "feature", + "description": "Optimize Kotlin API Reference documentation navigation sidebar" +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 6f0628e468b..73001e4559f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,8 @@ val testJavaVersion = typedProp("test.java.version")?.let { } allprojects { + apply(from = "${rootProject.file("buildSrc/src/main/kotlin/dokka-customization.gradle.kts")}") + tasks.withType().configureEach { val sdkVersion: String by project moduleVersion.set(sdkVersion) @@ -54,7 +56,8 @@ allprojects { "customAssets": [ "${rootProject.file("docs/dokka-presets/assets/logo-icon.svg")}", "${rootProject.file("docs/dokka-presets/assets/aws_logo_white_59x35.png")}", - "${rootProject.file("docs/dokka-presets/scripts/accessibility.js")}" + "${rootProject.file("docs/dokka-presets/scripts/accessibility.js")}", + "${rootProject.file("docs/dokka-presets/scripts/custom-navigation-loader.js")}" ], "footerMessage": "© $year, Amazon Web Services, Inc. or its affiliates. All rights reserved.", "separateInheritedMembers" : true, @@ -137,6 +140,8 @@ project.afterEvaluate { // NOTE: these get concatenated rootProject.file("docs/dokka-presets/README.md"), ) + + finalizedBy("trimNavigations", "applyCustomNavigationLoader") } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index dccd6941e76..766bd065595 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -5,3 +5,7 @@ plugins { repositories { mavenCentral() } + +dependencies { + implementation(libs.jsoup) +} diff --git a/buildSrc/src/main/kotlin/dokka-customization.gradle.kts b/buildSrc/src/main/kotlin/dokka-customization.gradle.kts new file mode 100644 index 00000000000..bb7c5d7bd4c --- /dev/null +++ b/buildSrc/src/main/kotlin/dokka-customization.gradle.kts @@ -0,0 +1,73 @@ +import org.jsoup.Jsoup + +tasks.register("trimNavigations") { + description = "Trims navigation files to remove unrelated child submenu" + group = "documentation" + + doLast { + val dokkaOutputDir = rootProject.buildDir.resolve("dokka/htmlMultiModule") + + if (!dokkaOutputDir.exists()) { + logger.info("Dokka output directory not found at ${dokkaOutputDir.absolutePath}, skipping navigation trimming") + return@doLast + } + + dokkaOutputDir.listFiles { file -> + file.isDirectory && file.resolve("navigation.html").exists() + }?.forEach { moduleDir -> + val moduleName = moduleDir.name + + val navFile = File(moduleDir, "navigation.html") + + val doc = Jsoup.parse(navFile, "UTF-8") + + // Fix navigation links + doc.select("a[href^='../']").forEach { anchor -> + val originalHref = anchor.attr("href") + val trimmedHref = originalHref.replace("../", "") + anchor.attr("href", trimmedHref) + } + + val sideMenuParts = doc.select("div.sideMenu > div.sideMenuPart") + + sideMenuParts.forEach { submenu -> + // If this is not the current module's submenu, remove all its nested content + if (submenu.id() != "$moduleName-nav-submenu") { + val overviewDiv = submenu.select("> div.overview").first() + overviewDiv?.select("span.navButton")?.remove() + submenu.children().remove() + if (overviewDiv != null) { + submenu.appendChild(overviewDiv) + } + } + } + + val wrappedContent = "
\n${sideMenuParts.outerHtml()}\n
" + navFile.writeText(wrappedContent) + } + } +} + +tasks.register("applyCustomNavigationLoader") { + group = "documentation" + description = "Replace default Dokka navigation-loader.js with custom implementation" + + doLast { + val dokkaOutputDir = rootProject.buildDir.resolve("dokka/htmlMultiModule") + + if (!dokkaOutputDir.exists()) { + logger.info("Dokka output directory not found at ${dokkaOutputDir.absolutePath}, skipping apply custom navigation loader") + return@doLast + } + + dokkaOutputDir.walkTopDown() + .filter { it.isFile && it.name.endsWith(".html") } + .forEach { file -> + val updatedContent = file.readLines().filterNot { line -> + line.contains("""scripts/navigation-loader.js""") + }.joinToString("\n") + + file.writeText(updatedContent) + } + } +} diff --git a/docs/dokka-presets/scripts/custom-navigation-loader.js b/docs/dokka-presets/scripts/custom-navigation-loader.js new file mode 100644 index 00000000000..a7a85f89676 --- /dev/null +++ b/docs/dokka-presets/scripts/custom-navigation-loader.js @@ -0,0 +1,152 @@ +// Custom navigation loader for AWS SDK for Kotlin documentation. + +// Extracts the module name from a given URL href +function extractModuleName(href) { + try{ + const url = new URL(href, window.location.origin); + const pathname = url.pathname; + const pathSegments = pathname.split('/').filter(Boolean); + + // For local hosting + if (url.hostname === 'localhost') { + return pathSegments.length >= 1 ? pathSegments[0] : null; + } + + return pathSegments.length >= 4 ? pathSegments[3] : null; + } + catch (error) { + return null; + } +} + +// Hides the sidebar and adjusts main content layout +function hideSidebar() { + const sidebar = document.getElementById('leftColumn'); + const main = document.getElementById('main'); + + if (sidebar) { + sidebar.style.display = 'none'; + } + + if (main) { + main.style.marginLeft = '0'; + main.style.width = '100%'; + } +} + +function loadNavigation() { + const moduleName = extractModuleName(window.location.href); + + // Hide sidebar for root index page + if (moduleName === "index.html") { + hideSidebar() + return Promise.resolve(''); + } + + const navigationPath = moduleName + ? `${pathToRoot}${moduleName}/navigation.html` + : `${pathToRoot}navigation.html`; + + return fetch(navigationPath) + .then(response => response.text()) + .catch(error => { + // Use root navigation as a fallback + return fetch(pathToRoot + "navigation.html") + .then(response => response.text()); + }); +} + +navigationPageText = loadNavigation() + +// ================================================================= +// Everything below this is copied from Dokka's navigation-loader.js +// ================================================================= +displayNavigationFromPage = () => { + navigationPageText.then(data => { + document.getElementById("sideMenu").innerHTML = data; + }).then(() => { + document.querySelectorAll(".overview > a").forEach(link => { + link.setAttribute("href", pathToRoot + link.getAttribute("href")); + }) + }).then(() => { + document.querySelectorAll(".sideMenuPart").forEach(nav => { + if (!nav.classList.contains("hidden")) + nav.classList.add("hidden") + }) + }).then(() => { + revealNavigationForCurrentPage() + }).then(() => { + scrollNavigationToSelectedElement() + }) + document.querySelectorAll('.footer a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + document.querySelector(this.getAttribute('href')).scrollIntoView({ + behavior: 'smooth' + }); + }); + }); +} + +revealNavigationForCurrentPage = () => { + let pageId = document.getElementById("content").attributes["pageIds"].value.toString(); + let parts = document.querySelectorAll(".sideMenuPart"); + let found = 0; + do { + parts.forEach(part => { + if (part.attributes['pageId'].value.indexOf(pageId) !== -1 && found === 0) { + found = 1; + if (part.classList.contains("hidden")) { + part.classList.remove("hidden"); + part.setAttribute('data-active', ""); + } + revealParents(part) + } + }); + pageId = pageId.substring(0, pageId.lastIndexOf("/")) + } while (pageId.indexOf("/") !== -1 && found === 0) +}; +revealParents = (part) => { + if (part.classList.contains("sideMenuPart")) { + if (part.classList.contains("hidden")) + part.classList.remove("hidden"); + revealParents(part.parentNode) + } +}; + +scrollNavigationToSelectedElement = () => { + let selectedElement = document.querySelector('div.sideMenuPart[data-active]') + if (selectedElement == null) { // nothing selected, probably just the main page opened + return + } + + let hasIcon = selectedElement.querySelectorAll(":scope > div.overview span.nav-icon").length > 0 + + // for instance enums also have children and are expandable, but are not package/module elements + let isPackageElement = selectedElement.children.length > 1 && !hasIcon + if (isPackageElement) { + // if package is selected or linked, it makes sense to align it to top + // so that you can see all the members it contains + selectedElement.scrollIntoView(true) + } else { + // if a member within a package is linked, it makes sense to center it since it, + // this should make it easier to look at surrounding members + selectedElement.scrollIntoView({ + behavior: 'auto', + block: 'center', + inline: 'center' + }) + } +} + +/* + This is a work-around for safari being IE of our times. + It doesn't fire a DOMContentLoaded, presumabely because eventListener is added after it wants to do it +*/ +if (document.readyState == 'loading') { + window.addEventListener('DOMContentLoaded', () => { + displayNavigationFromPage() + }) +} else { + displayNavigationFromPage() +}