Skip to content

Commit 724fb71

Browse files
Backport "Scaladoc - add option for dynamic side menu" to LTS (#20830)
Backports #19337 to the LTS branch. PR submitted by the release tooling. [skip ci]
2 parents 902e332 + a9962c1 commit 724fb71

File tree

5 files changed

+215
-31
lines changed

5 files changed

+215
-31
lines changed

project/ScaladocGeneration.scala

+4
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

+170-25
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

+4-2
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

+5-2
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-
def scaladocSpecificSettings: Set[Setting[_]] =
137-
Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks)
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+
139+
def scaladocSpecificSettings: Set[Setting[?]] =
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

+32-2
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)