Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scaladoc - add option for dynamic side menu #19337

Merged
merged 1 commit into from
Jan 16, 2024
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
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be dynamic as a default for now (so the default behavior doesn't change). We can change it to default after we ensure it works in practice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false is the old behavior

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, right, this one if confused me. So everything looks alright!

)

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
Loading