diff --git a/project/ScaladocGeneration.scala b/project/ScaladocGeneration.scala index ade9b65d5445..aac9f187a888 100644 --- a/project/ScaladocGeneration.scala +++ b/project/ScaladocGeneration.scala @@ -137,6 +137,10 @@ object ScaladocGeneration { def key: String = "-quick-links" } + case class DynamicSideMenu(value: Boolean) extends Arg[Boolean] { + def key: String = "-dynamic-side-menu" + } + import _root_.scala.reflect._ trait GenerationConfig { diff --git a/scaladoc/resources/dotty_res/scripts/ux.js b/scaladoc/resources/dotty_res/scripts/ux.js index dd6e798f17a5..7b875fbcef8e 100644 --- a/scaladoc/resources/dotty_res/scripts/ux.js +++ b/scaladoc/resources/dotty_res/scripts/ux.js @@ -4,6 +4,8 @@ const attrsToCopy = [ "data-githubContributorsUrl", "data-githubContributorsFilename", "data-pathToRoot", + "data-rawLocation", + "data-dynamicSideMenu", ] /** @@ -25,7 +27,7 @@ function savePageState(doc) { } return { mainDiv: doc.querySelector("#main")?.innerHTML, - leftColumn: doc.querySelector("#leftColumn").innerHTML, + leftColumn: dynamicSideMenu ? null : doc.querySelector("#leftColumn").innerHTML, title: doc.title, attrs, }; @@ -38,12 +40,15 @@ function savePageState(doc) { function loadPageState(doc, saved) { doc.title = saved.title; doc.querySelector("#main").innerHTML = saved.mainDiv; - doc.querySelector("#leftColumn").innerHTML = saved.leftColumn; + if (!dynamicSideMenu) + doc.querySelector("#leftColumn").innerHTML = saved.leftColumn; for (const attr of attrsToCopy) { doc.documentElement.setAttribute(attr, saved.attrs[attr]); } } +const attachedElements = new WeakSet() + function attachAllListeners() { if (observer) { observer.disconnect(); @@ -97,19 +102,19 @@ function attachAllListeners() { } } -document - .querySelectorAll(".documentableElement .signature") - .forEach((signature) => { - const short = signature.querySelector(".signature-short"); - const long = signature.querySelector(".signature-long"); - const extender = document.createElement("span"); - const extenderDots = document.createTextNode("..."); - extender.appendChild(extenderDots); - extender.classList.add("extender"); - if (short && long && signature.children[1].hasChildNodes()) { - signature.children[0].append(extender); - } - }); + document + .querySelectorAll(".documentableElement .signature") + .forEach((signature) => { + const short = signature.querySelector(".signature-short"); + const long = signature.querySelector(".signature-long"); + const extender = document.createElement("span"); + const extenderDots = document.createTextNode("..."); + extender.appendChild(extenderDots); + extender.classList.add("extender"); + if (short && long && signature.children[1].hasChildNodes()) { + signature.children[0].append(extender); + } + }); const documentableLists = document.getElementsByClassName("documentableList"); [...documentableLists].forEach((list) => { @@ -151,6 +156,8 @@ document return; } const url = new URL(href); + if (attachedElements.has(el)) return; + attachedElements.add(el); el.addEventListener("click", (e) => { if ( url.href.replace(/#.*/, "") === window.location.href.replace(/#.*/, "") @@ -166,6 +173,7 @@ document e.preventDefault(); e.stopPropagation(); $.get(href, function (data) { + const oldLoc = getRawLoc(); if (window.history.state === null) { window.history.replaceState(savePageState(document), ""); } @@ -174,6 +182,11 @@ document const state = savePageState(parsedDocument); window.history.pushState(state, "", href); loadPageState(document, state); + const newLoc = getRawLoc(); + if (dynamicSideMenu) { + updateMenu(oldLoc, newLoc); + } + window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD)); document .querySelector("#main") @@ -182,11 +195,15 @@ document }); }); - $(".ar").on("click", function (e) { - $(this).parent().parent().toggleClass("expanded"); - $(this).toggleClass("expanded"); - e.stopPropagation(); - }); + document.querySelectorAll('.ar').forEach((el) => { + if (attachedElements.has(el)) return; + attachedElements.add(el); + el.addEventListener('click', (e) => { + e.stopPropagation(); + el.parentElement.parentElement.classList.toggle("expanded"); + el.classList.toggle("expanded"); + }) + }) document.querySelectorAll(".documentableList .ar").forEach((arrow) => { arrow.addEventListener("click", () => { @@ -195,7 +212,9 @@ document }); }); - document.querySelectorAll(".nh").forEach((el) => + document.querySelectorAll(".nh").forEach((el) => { + if (attachedElements.has(el)) return; + attachedElements.add(el); el.addEventListener("click", () => { if ( el.lastChild.href.replace("#", "") === @@ -206,8 +225,8 @@ document } else { el.lastChild.click(); } - }), - ); + }); + }); const toggleShowAllElem = (element) => { if (element.textContent == "Show all") { @@ -345,7 +364,7 @@ window.addEventListener(DYNAMIC_PAGE_LOAD, () => { attachAllListeners(); }); -window.addEventListener("dynamicPageLoad", () => { +window.addEventListener(DYNAMIC_PAGE_LOAD, () => { const sideMenuOpen = sessionStorage.getItem("sideMenuOpen"); if (sideMenuOpen) { if (document.querySelector("#leftColumn").classList.contains("show")) { @@ -365,10 +384,136 @@ window.addEventListener("dynamicPageLoad", () => { } }); +let dynamicSideMenu = false; +/** @param {Element} elem @param {boolean} hide */ +function updatePath(elem, hide, first = true) { + if (elem.classList.contains("side-menu")) return; + const span = elem.firstElementChild + const btn = span.firstElementChild + if (hide) { + elem.classList.remove("expanded"); + span.classList.remove("h100", "selected", "expanded", "cs"); + if (btn) btn.classList.remove("expanded"); + } else { + elem.classList.add("expanded"); + span.classList.add("h100", "expanded", "cs"); + if (btn) btn.classList.add("expanded"); + if (first) span.classList.add("selected"); + } + updatePath(elem.parentElement, hide, false); +} +let updateMenu = null; +function getRawLoc() { + return document.documentElement.getAttribute("data-rawLocation")?.split("/")?.filter(c => c !== ""); +} + +/** + * @template {keyof HTMLElementTagNameMap} T + * @param {T} el type of element to create + * @param {{ cls?: string | null, id?: string | null, href?: string | null }} attrs element attributes + * @param {Array} chldr element children + * @returns {HTMLElementTagNameMap[T]} + */ +function render(el, { cls = null, id = null, href = null, loc = null } = {}, chldr = []) { + const r = document.createElement(el); + if (cls) cls.split(" ").filter(x => x !== "").forEach(c => r.classList.add(c)); + if (id) r.id = id; + if (href) r.href = href; + if (loc) r.setAttribute("data-loc", loc); + chldr.filter(c => c !== null).forEach(c => + r.appendChild(typeof c === "string" ? document.createTextNode(c) : c) + ); + return r; +} +function renderDynamicSideMenu() { + const pathToRoot = document.documentElement.getAttribute("data-pathToRoot") + const path = pathToRoot + "dynamicSideMenu.json"; + const rawLocation = getRawLoc(); + const baseUrl = window.location.pathname.split("/").slice(0, + -1 - pathToRoot.split("/").filter(c => c != "").length + ); + function linkTo(loc) { + return `${baseUrl}/${loc.join("/")}.html`; + } + fetch(path).then(r => r.json()).then(menu => { + function renderNested(item, nestLevel, prefix, isApi) { + const name = item.name; + const newName = + isApi && item.kind === "package" && name.startsWith(prefix + ".") + ? name.substring(prefix.length + 1) + : name; + const newPrefix = + prefix == "" + ? newName + : prefix + "." + newName; + const chldr = + item.children.map(x => renderNested(x, nestLevel + 1, newPrefix, isApi)); + const link = render("span", { cls: `nh ${isApi ? "" : "de"}` }, [ + chldr.length ? render("button", { cls: "ar icon-button" }) : null, + render("a", { href: linkTo(item.location) }, [ + item.kind && render("span", { cls: `micon ${item.kind.slice(0, 2)}` }), + render("span", {}, [newName]), + ]), + ]); + const loc = item.location.join("/"); + const ret = render("div", { cls: `ni n${nestLevel}`, loc: item.location.join("/") }, [link, ...chldr]); + return ret; + } + const d = render("div", { cls: "switcher-container" }, [ + menu.docs && render("a", { + id: "docs-nav-button", + cls: "switcher h100", + href: linkTo(menu.docs.location) + }, ["Docs"]), + menu.api && render("a", { + id: "api-nav-button", + cls: "switcher h100", + href: linkTo(menu.api.location) + }, ["API"]), + ]); + const d1 = menu.docs && render("nav", { cls: "side-menu", id: "docs-nav" }, + menu.docs.children.map(item => renderNested(item, 0, "", false)) + ); + const d2 = menu.api && render("nav", { cls: "side-menu", id: "api-nav" }, + menu.api.children.map(item => renderNested(item, 0, "", true)) + ); + + document.getElementById("leftColumn").appendChild(d); + d1 && document.getElementById("leftColumn").appendChild(d1); + d2 && document.getElementById("leftColumn").appendChild(d2); + updateMenu = (oldLoc, newLoc) => { + if (oldLoc) { + const elem = document.querySelector(`[data-loc="${oldLoc.join("/")}"]`); + if (elem) updatePath(elem, true); + } + if (d1 && d2) { + if (newLoc[0] && newLoc[0] == menu.api.location[0]) { + d1.hidden = true; + d2.hidden = false; + } else { + d1.hidden = false; + d2.hidden = true; + } + } + const elem = document.querySelector(`[data-loc="${newLoc.join("/")}"]`); + if (elem) updatePath(elem, false) + } + updateMenu(null, rawLocation); + + window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD)); + }) +} + window.addEventListener("DOMContentLoaded", () => { hljs.registerLanguage("scala", highlightDotty); hljs.registerAliases(["dotty", "scala3"], "scala"); - window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD)); + + dynamicSideMenu = document.documentElement.getAttribute("data-dynamicSideMenu") === "true"; + if (dynamicSideMenu) { + renderDynamicSideMenu(); + } else { + window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD)); + } }); const elements = document.querySelectorAll(".documentableElement"); diff --git a/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala b/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala index 260529b024db..a2485085a927 100644 --- a/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala +++ b/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala @@ -45,7 +45,8 @@ object Scaladoc: apiSubdirectory : Boolean = false, scastieConfiguration: String = "", defaultTemplate: Option[String] = None, - quickLinks: List[QuickLink] = List.empty + quickLinks: List[QuickLink] = List.empty, + dynamicSideMenu: Boolean = false, ) def run(args: Array[String], rootContext: CompilerContext): Reporter = @@ -228,7 +229,8 @@ object Scaladoc: apiSubdirectory.get, scastieConfiguration.get, defaultTemplate.nonDefault, - quickLinksParsed + quickLinksParsed, + dynamicSideMenu.get, ) (Some(docArgs), newContext) } diff --git a/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala b/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala index 403acb19305d..4802f5d24ecc 100644 --- a/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala +++ b/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala @@ -133,5 +133,8 @@ class ScaladocSettings extends SettingGroup with AllScalaSettings: "List of quick links that is displayed in the header of documentation." ) + val dynamicSideMenu: Setting[Boolean] = + BooleanSetting("-dynamic-side-menu", "Generate side menu via JS instead of embedding it in every html file", false) + def scaladocSpecificSettings: Set[Setting[?]] = - Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks) + Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks, dynamicSideMenu) diff --git a/scaladoc/src/dotty/tools/scaladoc/renderers/HtmlRenderer.scala b/scaladoc/src/dotty/tools/scaladoc/renderers/HtmlRenderer.scala index 20f3335a44ef..7dff3e5637c6 100644 --- a/scaladoc/src/dotty/tools/scaladoc/renderers/HtmlRenderer.scala +++ b/scaladoc/src/dotty/tools/scaladoc/renderers/HtmlRenderer.scala @@ -31,6 +31,8 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do case _ => Nil case _ => Nil) :+ (Attr("data-pathToRoot") := pathToRoot(page.link.dri)) + :+ (Attr("data-rawLocation") := rawLocation(page.link.dri).mkString("/")) + :+ (Attr("data-dynamicSideMenu") := ctx.args.dynamicSideMenu.toString) val htmlTag = html(attrs*)( head((mkHead(page) :+ docHead)*), @@ -46,8 +48,35 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do override def render(): Unit = val renderedResources = renderResources() + if ctx.args.dynamicSideMenu then serializeSideMenu() super.render() + private def serializeSideMenu() = + import com.fasterxml.jackson.databind.* + import com.fasterxml.jackson.databind.node.ObjectNode + import com.fasterxml.jackson.databind.node.TextNode + val mapper = new ObjectMapper(); + + def serializePage(page: Page): ObjectNode = + import scala.jdk.CollectionConverters.SeqHasAsJava + val children = mapper.createArrayNode().addAll(page.children.filterNot(_.hidden).map(serializePage).asJava) + val location = mapper.createArrayNode().addAll(rawLocation(page.link.dri).map(TextNode(_)).asJava) + val obj = mapper.createObjectNode() + obj.set("name", new TextNode(page.link.name)) + obj.set("location", location) + obj.set("kind", page.content match + case m: Member if m.needsOwnPage => new TextNode(m.kind.name) + case _ => null + ) + obj.set("children", children) + obj + + val rootNode = mapper.createObjectNode() + rootNode.set("docs", rootDocsPage.map(serializePage).orNull) + rootNode.set("api", rootApiPage.map(serializePage).orNull) + val jsonString = mapper.writer().writeValueAsString(rootNode); + renderResource(Resource.Text("dynamicSideMenu.json", jsonString)) + private def renderResources(): Seq[String] = import scala.util.Using import scala.jdk.CollectionConverters._ @@ -218,7 +247,8 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do )).dropRight(1) div(cls := "breadcrumbs container")(innerTags*) - val (apiNavOpt, docsNavOpt): (Option[(Boolean, Seq[AppliedTag])], Option[(Boolean, Seq[AppliedTag])]) = buildNavigation(link) + val dynamicSideMenu = ctx.args.dynamicSideMenu + val (apiNavOpt, docsNavOpt) = if dynamicSideMenu then (None, None) else buildNavigation(link) def textFooter: String = args.projectFooter.getOrElse("") @@ -266,7 +296,7 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do ), span(id := "mobile-sidebar-toggle", cls := "floating-button"), div(id := "leftColumn", cls := "body-small")( - Seq( + if dynamicSideMenu then Nil else Seq( div(cls:= "switcher-container")( docsNavOpt match { case Some(isDocsActive, docsNav) =>