Skip to content

Commit 2945fd1

Browse files
authored
Scaladoc - add option for dynamic side menu (#19337)
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.
2 parents 31f837e + 075ee34 commit 2945fd1

File tree

5 files changed

+214
-30
lines changed

5 files changed

+214
-30
lines changed

project/ScaladocGeneration.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ object ScaladocGeneration {
137137
def key: String = "-quick-links"
138138
}
139139

140+
case class DynamicSideMenu(value: Boolean) extends Arg[Boolean] {
141+
def key: String = "-dynamic-side-menu"
142+
}
143+
140144
import _root_.scala.reflect._
141145

142146
trait GenerationConfig {

scaladoc/resources/dotty_res/scripts/ux.js

Lines changed: 170 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const attrsToCopy = [
44
"data-githubContributorsUrl",
55
"data-githubContributorsFilename",
66
"data-pathToRoot",
7+
"data-rawLocation",
8+
"data-dynamicSideMenu",
79
]
810

911
/**
@@ -25,7 +27,7 @@ function savePageState(doc) {
2527
}
2628
return {
2729
mainDiv: doc.querySelector("#main")?.innerHTML,
28-
leftColumn: doc.querySelector("#leftColumn").innerHTML,
30+
leftColumn: dynamicSideMenu ? null : doc.querySelector("#leftColumn").innerHTML,
2931
title: doc.title,
3032
attrs,
3133
};
@@ -38,12 +40,15 @@ function savePageState(doc) {
3840
function loadPageState(doc, saved) {
3941
doc.title = saved.title;
4042
doc.querySelector("#main").innerHTML = saved.mainDiv;
41-
doc.querySelector("#leftColumn").innerHTML = saved.leftColumn;
43+
if (!dynamicSideMenu)
44+
doc.querySelector("#leftColumn").innerHTML = saved.leftColumn;
4245
for (const attr of attrsToCopy) {
4346
doc.documentElement.setAttribute(attr, saved.attrs[attr]);
4447
}
4548
}
4649

50+
const attachedElements = new WeakSet()
51+
4752
function attachAllListeners() {
4853
if (observer) {
4954
observer.disconnect();
@@ -97,19 +102,19 @@ function attachAllListeners() {
97102
}
98103
}
99104

100-
document
101-
.querySelectorAll(".documentableElement .signature")
102-
.forEach((signature) => {
103-
const short = signature.querySelector(".signature-short");
104-
const long = signature.querySelector(".signature-long");
105-
const extender = document.createElement("span");
106-
const extenderDots = document.createTextNode("...");
107-
extender.appendChild(extenderDots);
108-
extender.classList.add("extender");
109-
if (short && long && signature.children[1].hasChildNodes()) {
110-
signature.children[0].append(extender);
111-
}
112-
});
105+
document
106+
.querySelectorAll(".documentableElement .signature")
107+
.forEach((signature) => {
108+
const short = signature.querySelector(".signature-short");
109+
const long = signature.querySelector(".signature-long");
110+
const extender = document.createElement("span");
111+
const extenderDots = document.createTextNode("...");
112+
extender.appendChild(extenderDots);
113+
extender.classList.add("extender");
114+
if (short && long && signature.children[1].hasChildNodes()) {
115+
signature.children[0].append(extender);
116+
}
117+
});
113118

114119
const documentableLists = document.getElementsByClassName("documentableList");
115120
[...documentableLists].forEach((list) => {
@@ -151,6 +156,8 @@ document
151156
return;
152157
}
153158
const url = new URL(href);
159+
if (attachedElements.has(el)) return;
160+
attachedElements.add(el);
154161
el.addEventListener("click", (e) => {
155162
if (
156163
url.href.replace(/#.*/, "") === window.location.href.replace(/#.*/, "")
@@ -166,6 +173,7 @@ document
166173
e.preventDefault();
167174
e.stopPropagation();
168175
$.get(href, function (data) {
176+
const oldLoc = getRawLoc();
169177
if (window.history.state === null) {
170178
window.history.replaceState(savePageState(document), "");
171179
}
@@ -174,6 +182,11 @@ document
174182
const state = savePageState(parsedDocument);
175183
window.history.pushState(state, "", href);
176184
loadPageState(document, state);
185+
const newLoc = getRawLoc();
186+
if (dynamicSideMenu) {
187+
updateMenu(oldLoc, newLoc);
188+
}
189+
177190
window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD));
178191
document
179192
.querySelector("#main")
@@ -182,11 +195,15 @@ document
182195
});
183196
});
184197

185-
$(".ar").on("click", function (e) {
186-
$(this).parent().parent().toggleClass("expanded");
187-
$(this).toggleClass("expanded");
188-
e.stopPropagation();
189-
});
198+
document.querySelectorAll('.ar').forEach((el) => {
199+
if (attachedElements.has(el)) return;
200+
attachedElements.add(el);
201+
el.addEventListener('click', (e) => {
202+
e.stopPropagation();
203+
el.parentElement.parentElement.classList.toggle("expanded");
204+
el.classList.toggle("expanded");
205+
})
206+
})
190207

191208
document.querySelectorAll(".documentableList .ar").forEach((arrow) => {
192209
arrow.addEventListener("click", () => {
@@ -195,7 +212,9 @@ document
195212
});
196213
});
197214

198-
document.querySelectorAll(".nh").forEach((el) =>
215+
document.querySelectorAll(".nh").forEach((el) => {
216+
if (attachedElements.has(el)) return;
217+
attachedElements.add(el);
199218
el.addEventListener("click", () => {
200219
if (
201220
el.lastChild.href.replace("#", "") ===
@@ -206,8 +225,8 @@ document
206225
} else {
207226
el.lastChild.click();
208227
}
209-
}),
210-
);
228+
});
229+
});
211230

212231
const toggleShowAllElem = (element) => {
213232
if (element.textContent == "Show all") {
@@ -345,7 +364,7 @@ window.addEventListener(DYNAMIC_PAGE_LOAD, () => {
345364
attachAllListeners();
346365
});
347366

348-
window.addEventListener("dynamicPageLoad", () => {
367+
window.addEventListener(DYNAMIC_PAGE_LOAD, () => {
349368
const sideMenuOpen = sessionStorage.getItem("sideMenuOpen");
350369
if (sideMenuOpen) {
351370
if (document.querySelector("#leftColumn").classList.contains("show")) {
@@ -365,10 +384,136 @@ window.addEventListener("dynamicPageLoad", () => {
365384
}
366385
});
367386

387+
let dynamicSideMenu = false;
388+
/** @param {Element} elem @param {boolean} hide */
389+
function updatePath(elem, hide, first = true) {
390+
if (elem.classList.contains("side-menu")) return;
391+
const span = elem.firstElementChild
392+
const btn = span.firstElementChild
393+
if (hide) {
394+
elem.classList.remove("expanded");
395+
span.classList.remove("h100", "selected", "expanded", "cs");
396+
if (btn) btn.classList.remove("expanded");
397+
} else {
398+
elem.classList.add("expanded");
399+
span.classList.add("h100", "expanded", "cs");
400+
if (btn) btn.classList.add("expanded");
401+
if (first) span.classList.add("selected");
402+
}
403+
updatePath(elem.parentElement, hide, false);
404+
}
405+
let updateMenu = null;
406+
function getRawLoc() {
407+
return document.documentElement.getAttribute("data-rawLocation")?.split("/")?.filter(c => c !== "");
408+
}
409+
410+
/**
411+
* @template {keyof HTMLElementTagNameMap} T
412+
* @param {T} el type of element to create
413+
* @param {{ cls?: string | null, id?: string | null, href?: string | null }} attrs element attributes
414+
* @param {Array<HTMLElement | string | null>} chldr element children
415+
* @returns {HTMLElementTagNameMap[T]}
416+
*/
417+
function render(el, { cls = null, id = null, href = null, loc = null } = {}, chldr = []) {
418+
const r = document.createElement(el);
419+
if (cls) cls.split(" ").filter(x => x !== "").forEach(c => r.classList.add(c));
420+
if (id) r.id = id;
421+
if (href) r.href = href;
422+
if (loc) r.setAttribute("data-loc", loc);
423+
chldr.filter(c => c !== null).forEach(c =>
424+
r.appendChild(typeof c === "string" ? document.createTextNode(c) : c)
425+
);
426+
return r;
427+
}
428+
function renderDynamicSideMenu() {
429+
const pathToRoot = document.documentElement.getAttribute("data-pathToRoot")
430+
const path = pathToRoot + "dynamicSideMenu.json";
431+
const rawLocation = getRawLoc();
432+
const baseUrl = window.location.pathname.split("/").slice(0,
433+
-1 - pathToRoot.split("/").filter(c => c != "").length
434+
);
435+
function linkTo(loc) {
436+
return `${baseUrl}/${loc.join("/")}.html`;
437+
}
438+
fetch(path).then(r => r.json()).then(menu => {
439+
function renderNested(item, nestLevel, prefix, isApi) {
440+
const name = item.name;
441+
const newName =
442+
isApi && item.kind === "package" && name.startsWith(prefix + ".")
443+
? name.substring(prefix.length + 1)
444+
: name;
445+
const newPrefix =
446+
prefix == ""
447+
? newName
448+
: prefix + "." + newName;
449+
const chldr =
450+
item.children.map(x => renderNested(x, nestLevel + 1, newPrefix, isApi));
451+
const link = render("span", { cls: `nh ${isApi ? "" : "de"}` }, [
452+
chldr.length ? render("button", { cls: "ar icon-button" }) : null,
453+
render("a", { href: linkTo(item.location) }, [
454+
item.kind && render("span", { cls: `micon ${item.kind.slice(0, 2)}` }),
455+
render("span", {}, [newName]),
456+
]),
457+
]);
458+
const loc = item.location.join("/");
459+
const ret = render("div", { cls: `ni n${nestLevel}`, loc: item.location.join("/") }, [link, ...chldr]);
460+
return ret;
461+
}
462+
const d = render("div", { cls: "switcher-container" }, [
463+
menu.docs && render("a", {
464+
id: "docs-nav-button",
465+
cls: "switcher h100",
466+
href: linkTo(menu.docs.location)
467+
}, ["Docs"]),
468+
menu.api && render("a", {
469+
id: "api-nav-button",
470+
cls: "switcher h100",
471+
href: linkTo(menu.api.location)
472+
}, ["API"]),
473+
]);
474+
const d1 = menu.docs && render("nav", { cls: "side-menu", id: "docs-nav" },
475+
menu.docs.children.map(item => renderNested(item, 0, "", false))
476+
);
477+
const d2 = menu.api && render("nav", { cls: "side-menu", id: "api-nav" },
478+
menu.api.children.map(item => renderNested(item, 0, "", true))
479+
);
480+
481+
document.getElementById("leftColumn").appendChild(d);
482+
d1 && document.getElementById("leftColumn").appendChild(d1);
483+
d2 && document.getElementById("leftColumn").appendChild(d2);
484+
updateMenu = (oldLoc, newLoc) => {
485+
if (oldLoc) {
486+
const elem = document.querySelector(`[data-loc="${oldLoc.join("/")}"]`);
487+
if (elem) updatePath(elem, true);
488+
}
489+
if (d1 && d2) {
490+
if (newLoc[0] && newLoc[0] == menu.api.location[0]) {
491+
d1.hidden = true;
492+
d2.hidden = false;
493+
} else {
494+
d1.hidden = false;
495+
d2.hidden = true;
496+
}
497+
}
498+
const elem = document.querySelector(`[data-loc="${newLoc.join("/")}"]`);
499+
if (elem) updatePath(elem, false)
500+
}
501+
updateMenu(null, rawLocation);
502+
503+
window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD));
504+
})
505+
}
506+
368507
window.addEventListener("DOMContentLoaded", () => {
369508
hljs.registerLanguage("scala", highlightDotty);
370509
hljs.registerAliases(["dotty", "scala3"], "scala");
371-
window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD));
510+
511+
dynamicSideMenu = document.documentElement.getAttribute("data-dynamicSideMenu") === "true";
512+
if (dynamicSideMenu) {
513+
renderDynamicSideMenu();
514+
} else {
515+
window.dispatchEvent(new Event(DYNAMIC_PAGE_LOAD));
516+
}
372517
});
373518

374519
const elements = document.querySelectorAll(".documentableElement");

scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ object Scaladoc:
4545
apiSubdirectory : Boolean = false,
4646
scastieConfiguration: String = "",
4747
defaultTemplate: Option[String] = None,
48-
quickLinks: List[QuickLink] = List.empty
48+
quickLinks: List[QuickLink] = List.empty,
49+
dynamicSideMenu: Boolean = false,
4950
)
5051

5152
def run(args: Array[String], rootContext: CompilerContext): Reporter =
@@ -228,7 +229,8 @@ object Scaladoc:
228229
apiSubdirectory.get,
229230
scastieConfiguration.get,
230231
defaultTemplate.nonDefault,
231-
quickLinksParsed
232+
quickLinksParsed,
233+
dynamicSideMenu.get,
232234
)
233235
(Some(docArgs), newContext)
234236
}

scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,8 @@ class ScaladocSettings extends SettingGroup with AllScalaSettings:
133133
"List of quick links that is displayed in the header of documentation."
134134
)
135135

136+
val dynamicSideMenu: Setting[Boolean] =
137+
BooleanSetting("-dynamic-side-menu", "Generate side menu via JS instead of embedding it in every html file", false)
138+
136139
def scaladocSpecificSettings: Set[Setting[?]] =
137-
Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks)
140+
Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks, dynamicSideMenu)

scaladoc/src/dotty/tools/scaladoc/renderers/HtmlRenderer.scala

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do
3131
case _ => Nil
3232
case _ => Nil)
3333
:+ (Attr("data-pathToRoot") := pathToRoot(page.link.dri))
34+
:+ (Attr("data-rawLocation") := rawLocation(page.link.dri).mkString("/"))
35+
:+ (Attr("data-dynamicSideMenu") := ctx.args.dynamicSideMenu.toString)
3436

3537
val htmlTag = html(attrs*)(
3638
head((mkHead(page) :+ docHead)*),
@@ -46,8 +48,35 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do
4648

4749
override def render(): Unit =
4850
val renderedResources = renderResources()
51+
if ctx.args.dynamicSideMenu then serializeSideMenu()
4952
super.render()
5053

54+
private def serializeSideMenu() =
55+
import com.fasterxml.jackson.databind.*
56+
import com.fasterxml.jackson.databind.node.ObjectNode
57+
import com.fasterxml.jackson.databind.node.TextNode
58+
val mapper = new ObjectMapper();
59+
60+
def serializePage(page: Page): ObjectNode =
61+
import scala.jdk.CollectionConverters.SeqHasAsJava
62+
val children = mapper.createArrayNode().addAll(page.children.filterNot(_.hidden).map(serializePage).asJava)
63+
val location = mapper.createArrayNode().addAll(rawLocation(page.link.dri).map(TextNode(_)).asJava)
64+
val obj = mapper.createObjectNode()
65+
obj.set("name", new TextNode(page.link.name))
66+
obj.set("location", location)
67+
obj.set("kind", page.content match
68+
case m: Member if m.needsOwnPage => new TextNode(m.kind.name)
69+
case _ => null
70+
)
71+
obj.set("children", children)
72+
obj
73+
74+
val rootNode = mapper.createObjectNode()
75+
rootNode.set("docs", rootDocsPage.map(serializePage).orNull)
76+
rootNode.set("api", rootApiPage.map(serializePage).orNull)
77+
val jsonString = mapper.writer().writeValueAsString(rootNode);
78+
renderResource(Resource.Text("dynamicSideMenu.json", jsonString))
79+
5180
private def renderResources(): Seq[String] =
5281
import scala.util.Using
5382
import scala.jdk.CollectionConverters._
@@ -218,7 +247,8 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do
218247
)).dropRight(1)
219248
div(cls := "breadcrumbs container")(innerTags*)
220249

221-
val (apiNavOpt, docsNavOpt): (Option[(Boolean, Seq[AppliedTag])], Option[(Boolean, Seq[AppliedTag])]) = buildNavigation(link)
250+
val dynamicSideMenu = ctx.args.dynamicSideMenu
251+
val (apiNavOpt, docsNavOpt) = if dynamicSideMenu then (None, None) else buildNavigation(link)
222252

223253
def textFooter: String =
224254
args.projectFooter.getOrElse("")
@@ -266,7 +296,7 @@ class HtmlRenderer(rootPackage: Member, members: Map[DRI, Member])(using ctx: Do
266296
),
267297
span(id := "mobile-sidebar-toggle", cls := "floating-button"),
268298
div(id := "leftColumn", cls := "body-small")(
269-
Seq(
299+
if dynamicSideMenu then Nil else Seq(
270300
div(cls:= "switcher-container")(
271301
docsNavOpt match {
272302
case Some(isDocsActive, docsNav) =>

0 commit comments

Comments
 (0)