Skip to content

Commit

Permalink
Scaladoc - add option for dynamic side menu (#19337)
Browse files Browse the repository at this point in the history
Closes #18543 

This PR adds `-dynamic-side-menu` option to Scaladoc.
With this option Scaladoc doesn't generate side menu (packages etc.
tree) in html files. Instead it is serialized into .json file and
rendered on the client using Javascript.
  • Loading branch information
Florian3k authored Jan 16, 2024
2 parents 31f837e + 075ee34 commit 2945fd1
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 30 deletions.
4 changes: 4 additions & 0 deletions project/ScaladocGeneration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
195 changes: 170 additions & 25 deletions scaladoc/resources/dotty_res/scripts/ux.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const attrsToCopy = [
"data-githubContributorsUrl",
"data-githubContributorsFilename",
"data-pathToRoot",
"data-rawLocation",
"data-dynamicSideMenu",
]

/**
Expand All @@ -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,
};
Expand All @@ -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();
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(/#.*/, "")
Expand All @@ -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), "");
}
Expand All @@ -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")
Expand All @@ -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", () => {
Expand All @@ -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("#", "") ===
Expand All @@ -206,8 +225,8 @@ document
} else {
el.lastChild.click();
}
}),
);
});
});

const toggleShowAllElem = (element) => {
if (element.textContent == "Show all") {
Expand Down Expand Up @@ -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")) {
Expand All @@ -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<HTMLElement | string | null>} 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");
Expand Down
6 changes: 4 additions & 2 deletions scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -228,7 +229,8 @@ object Scaladoc:
apiSubdirectory.get,
scastieConfiguration.get,
defaultTemplate.nonDefault,
quickLinksParsed
quickLinksParsed,
dynamicSideMenu.get,
)
(Some(docArgs), newContext)
}
Expand Down
5 changes: 4 additions & 1 deletion scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
34 changes: 32 additions & 2 deletions scaladoc/src/dotty/tools/scaladoc/renderers/HtmlRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)*),
Expand All @@ -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._
Expand Down Expand Up @@ -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("")
Expand Down Expand Up @@ -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) =>
Expand Down

0 comments on commit 2945fd1

Please sign in to comment.