diff --git a/build.sbt b/build.sbt index 6a350adb4936..b592aeaded08 100644 --- a/build.sbt +++ b/build.sbt @@ -22,6 +22,7 @@ val `tasty-core-bootstrapped` = Build.`tasty-core-bootstrapped` val `tasty-core-scala2` = Build.`tasty-core-scala2` val scala3doc = Build.scala3doc val `scala3doc-testcases` = Build.`scala3doc-testcases` +val `scala3doc-js` = Build.`scala3doc-js` val `scala3-bench-run` = Build.`scala3-bench-run` val dist = Build.dist val `community-build` = Build.`community-build` diff --git a/project/Build.scala b/project/Build.scala index 19244bfb36ec..819ea1b0dcbb 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -28,6 +28,8 @@ import sbtbuildinfo.BuildInfoPlugin.autoImport._ import scala.util.Properties.isJavaAtLeast +import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._ + object MyScalaJSPlugin extends AutoPlugin { import Build._ @@ -1228,6 +1230,8 @@ object Build { lazy val `scala3doc` = project.in(file("scala3doc")).asScala3doc lazy val `scala3doc-testcases` = project.in(file("scala3doc-testcases")).asScala3docTestcases + lazy val `scala3doc-js` = project.in(file("scala3doc-js")).asScala3docJs + // sbt plugin to use Dotty in your own build, see // https://github.com/lampepfl/scala3-example-project for usage. lazy val `sbt-dotty` = project.in(file("sbt-dotty")). @@ -1640,6 +1644,19 @@ object Build { ), Compile / buildInfoKeys := Seq[BuildInfoKey](version), Compile / buildInfoPackage := "dotty.dokka", + Compile / resourceGenerators += Def.task { + val jsDestinationFile = (Compile / resourceManaged).value / "dotty_res" / "scripts" / "searchbar.js" + sbt.IO.copyFile((fullOptJS in Compile in `scala3doc-js`).value.data, jsDestinationFile) + Seq(jsDestinationFile) + }.taskValue, + Compile / resourceGenerators += Def.task { + val cssDesitnationFile = (Compile / resourceManaged).value / "dotty_res" / "styles" / "scala3doc-searchbar.css" + val cssSourceFile = (resourceDirectory in Compile in `scala3doc-js`).value / "scala3doc-searchbar.css" + FileFunction.cached(streams.value.cacheDirectory / "css-cache") { (in: Set[File]) => + in.headOption.map(sbt.IO.copyFile(_, cssDesitnationFile)) + Set(cssDesitnationFile) + }.apply(Set(cssSourceFile)).toSeq + }.taskValue, testDocumentationRoot := (baseDirectory.value / "test-documentations").getAbsolutePath, buildInfoPackage in Test := "dotty.dokka.test", BuildInfoPlugin.buildInfoScopedSettings(Test), @@ -1653,6 +1670,17 @@ object Build { def asScala3docTestcases: Project = project.dependsOn(`scala3-compiler-bootstrapped`).settings(commonBootstrappedSettings) + def asScala3docJs: Project = + project. + enablePlugins(MyScalaJSPlugin). + dependsOn(`scala3-library-bootstrappedJS`). + settings( + fork in Test := false, + scalaJSUseMainModuleInitializer := true, + libraryDependencies += ("org.scala-js" %%% "scalajs-dom" % "1.1.0").withDottyCompat(scalaVersion.value) + ) + + def asDist(implicit mode: Mode): Project = project. enablePlugins(PackPlugin). withCommonSettings. diff --git a/scala3doc-js/resources/scala3doc-searchbar.css b/scala3doc-js/resources/scala3doc-searchbar.css new file mode 100644 index 000000000000..accc9cae1a69 --- /dev/null +++ b/scala3doc-js/resources/scala3doc-searchbar.css @@ -0,0 +1,102 @@ +/* button */ +.search span { + background: #ED3522; + fill: #fff; + cursor: pointer; + border: none; + padding: 9px; + border-radius: 24px; + box-shadow: 0 0 16px #F27264; +} +.search span:hover { + fill: #F27264; +} + +@media(max-width: 576px) { + .search span { + background: none; + fill: var(--icon-color); + cursor: pointer; + border: none; + padding: 0; + box-shadow: none; + margin-top: 2px; + } + .search span:hover { + fill: var(--link-hover-fg); + } +} + +#scala3doc-search { + margin-top: 10px; + cursor: pointer; + position: fixed; + top: 0; + right: 20px; + z-index: 5; +} + +#scala3doc-searchbar.hidden { + display: none; +} + +#scala3doc-searchbar { + position: absolute; + top: 50px; + right: 40px; + width: calc(100% - 360px); + box-shadow: 0 2px 16px 0 rgba(0, 42, 76, 0.15); + font-size: 13px; + font-family: system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, sans-serif; +} + +#scala3doc-searchbar-input { + width: 100%; + min-height: 32px; + border: none; + border-bottom: 1px solid #bbb; + padding: 10px; +} + +#scala3doc-searchbar-input:focus { + outline: none; +} + +#scala3doc-searchbar-results { + background: white; + display: flex; + flex-direction: column; + max-height: 500px; + overflow: auto; +} + +.scala3doc-searchbar-result { + line-height: 32px; + padding-left: 10px; + padding-right: 10px; +} + +.scala3doc-searchbar-result:first-of-type { + margin-top: 10px; +} + +.scala3doc-searchbar-result:hover { + background-color: #d4edff; +} + +.scala3doc-searchbar-result a { + color: #1f2326; +} + +.scala3doc-searchbar-result .scala3doc-searchbar-location { + color: gray; +} + +#searchBar { + display: inline-flex; +} + +.pull-right { + float: right; + margin-left: auto +} diff --git a/scala3doc-js/src/Globals.scala b/scala3doc-js/src/Globals.scala new file mode 100644 index 000000000000..e85230dede37 --- /dev/null +++ b/scala3doc-js/src/Globals.scala @@ -0,0 +1,10 @@ +package dotty.dokka + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSGlobalScope + +@js.native +@JSGlobalScope +object Globals extends js.Object { + val pathToRoot: String = js.native +} \ No newline at end of file diff --git a/scala3doc-js/src/Main.scala b/scala3doc-js/src/Main.scala new file mode 100644 index 000000000000..6c179a1890a0 --- /dev/null +++ b/scala3doc-js/src/Main.scala @@ -0,0 +1,5 @@ +package dotty.dokka + +object Main extends App { + Searchbar() +} diff --git a/scala3doc-js/src/searchbar/PageEntry.scala b/scala3doc-js/src/searchbar/PageEntry.scala new file mode 100644 index 000000000000..046cf428ebf9 --- /dev/null +++ b/scala3doc-js/src/searchbar/PageEntry.scala @@ -0,0 +1,32 @@ +package dotty.dokka + +import scala.scalajs.js + +@js.native +trait PageEntryJS extends js.Object { + val name: String = js.native + val description: String = js.native + val location: String = js.native + val searchKeys: js.Array[String] = js.native +} + +case class PageEntry( + fullName: String, + description: String, + location: String, + shortName: String, + acronym: Option[String] +) + +object PageEntry { + private def createAcronym(s: String): Option[String] = + s.headOption.map(firstLetter => firstLetter.toString ++ s.tail.filter(_.isUpper)) + + def apply(jsObj: PageEntryJS): PageEntry = PageEntry( + jsObj.name, + jsObj.description, + jsObj.location, + jsObj.searchKeys.head.toLowerCase, + createAcronym(jsObj.searchKeys.head) + ) +} diff --git a/scala3doc-js/src/searchbar/Searchbar.scala b/scala3doc-js/src/searchbar/Searchbar.scala new file mode 100644 index 000000000000..65347a9bd116 --- /dev/null +++ b/scala3doc-js/src/searchbar/Searchbar.scala @@ -0,0 +1,8 @@ +package dotty.dokka + +class Searchbar { + val pages = SearchbarGlobals.pages.toList.map(PageEntry.apply) + val engine = SearchbarEngine(pages) + val parser = QueryParser() + val component = SearchbarComponent(q => engine.query(parser.parse(q))) +} diff --git a/scala3doc-js/src/searchbar/SearchbarComponent.scala b/scala3doc-js/src/searchbar/SearchbarComponent.scala new file mode 100644 index 000000000000..e247ddab862f --- /dev/null +++ b/scala3doc-js/src/searchbar/SearchbarComponent.scala @@ -0,0 +1,102 @@ +package dotty.dokka + +import org.scalajs.dom._ +import org.scalajs.dom.html.Input + +class SearchbarComponent(val callback: (String) => List[PageEntry]): + val resultsChunkSize = 100 + extension (p: PageEntry) + def toHTML = + val wrapper = document.createElement("div").asInstanceOf[html.Div] + wrapper.classList.add("scala3doc-searchbar-result") + wrapper.classList.add("monospace") + + val resultA = document.createElement("a").asInstanceOf[html.Anchor] + resultA.href = Globals.pathToRoot + p.location + resultA.text = s"${p.fullName}" + + val location = document.createElement("span") + location.classList.add("pull-right") + location.classList.add("scala3doc-searchbar-location") + location.textContent = p.description + + wrapper.appendChild(resultA) + wrapper.appendChild(location) + wrapper + + def handleNewQuery(query: String) = + val result = callback(query).map(_.toHTML) + resultsDiv.scrollTop = 0 + while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild) + val fragment = document.createDocumentFragment() + result.take(resultsChunkSize).foreach(fragment.appendChild) + resultsDiv.appendChild(fragment) + def loadMoreResults(result: List[raw.HTMLElement]): Unit = { + resultsDiv.onscroll = (event: Event) => { + if (resultsDiv.scrollHeight - resultsDiv.scrollTop == resultsDiv.clientHeight) + { + val fragment = document.createDocumentFragment() + result.take(resultsChunkSize).foreach(fragment.appendChild) + resultsDiv.appendChild(fragment) + loadMoreResults(result.drop(resultsChunkSize)) + } + } + } + loadMoreResults(result.drop(resultsChunkSize)) + + private val searchIcon: html.Div = + val span = document.createElement("span").asInstanceOf[html.Span] + span.innerHTML = """""" + span.id = "scala3doc-search" + span.onclick = (event: Event) => + if (document.body.contains(rootDiv)) { + document.body.removeChild(rootDiv) + } + else document.body.appendChild(rootDiv) + + val element = createNestingDiv("search-content")( + createNestingDiv("search-container")( + createNestingDiv("search")( + span + ) + ) + ) + document.getElementById("scala3doc-searchBar").appendChild(element) + element + + + private val input: html.Input = + val element = document.createElement("input").asInstanceOf[html.Input] + element.id = "scala3doc-searchbar-input" + element.addEventListener("input", (e) => handleNewQuery(e.target.asInstanceOf[html.Input].value)) + element + + private val resultsDiv: html.Div = + val element = document.createElement("div").asInstanceOf[html.Div] + element.id = "scala3doc-searchbar-results" + element + + private val rootHiddenClasses = "hidden" + private val rootShowClasses = "" + + private def createNestingDiv(className: String)(innerElement: html.Element): html.Div = + val element = document.createElement("div").asInstanceOf[html.Div] + element.className = className + element.appendChild(innerElement) + element + + private val rootDiv: html.Div = + val element = document.createElement("div").asInstanceOf[html.Div] + element.addEventListener("mousedown", (e: Event) => e.stopPropagation()) + searchIcon.addEventListener("mousedown", (e: Event) => e.stopPropagation()) + document.body.addEventListener("mousedown", (e: Event) => + if (document.body.contains(element)) { + document.body.removeChild(element) + } + ) + element.id = "scala3doc-searchbar" + element.appendChild(input) + element.appendChild(resultsDiv) + element + + handleNewQuery("") diff --git a/scala3doc-js/src/searchbar/SearchbarGlobals.scala b/scala3doc-js/src/searchbar/SearchbarGlobals.scala new file mode 100644 index 000000000000..3daa50cba493 --- /dev/null +++ b/scala3doc-js/src/searchbar/SearchbarGlobals.scala @@ -0,0 +1,10 @@ +package dotty.dokka + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSGlobalScope + +@js.native +@JSGlobalScope +object SearchbarGlobals extends js.Object { + val pages: js.Array[PageEntryJS] = js.native +} \ No newline at end of file diff --git a/scala3doc-js/src/searchbar/engine/Matchers.scala b/scala3doc-js/src/searchbar/engine/Matchers.scala new file mode 100644 index 000000000000..faa3dc041d86 --- /dev/null +++ b/scala3doc-js/src/searchbar/engine/Matchers.scala @@ -0,0 +1,23 @@ +package dotty.dokka + +enum Matchers extends Function1[PageEntry, Int]: + case ByName(query: String) + case ByKind(kind: String) + + def apply(p: PageEntry): Int = this match { + case ByName(query) => { + val nameOption = Option(p.shortName) + val acronym = p.acronym + //Edge case for empty query string + if query == "" then 1 + else { + val results = List( + nameOption.filter(_.contains(query.toLowerCase)).fold(-1)(_.size - query.size), + acronym.filter(_.contains(query)).fold(-1)(_.size - query.size + 1) + ) + if results.forall(_ == -1) then -1 else results.filter(_ != -1).min + } + } + case ByKind(kind) => p.fullName.split(" ").headOption.filter(_.equalsIgnoreCase(kind)).fold(-1)(_ => 1) + } + diff --git a/scala3doc-js/src/searchbar/engine/QueryParser.scala b/scala3doc-js/src/searchbar/engine/QueryParser.scala new file mode 100644 index 000000000000..7d025c8e9866 --- /dev/null +++ b/scala3doc-js/src/searchbar/engine/QueryParser.scala @@ -0,0 +1,28 @@ +package dotty.dokka + +import scala.util.matching.Regex._ +import scala.util.matching._ + +class QueryParser: + val kinds = Seq( + "class", + "trait", + "enum", + "object", + "def", + "val", + "var", + "package", + "given", + "type" + ) + val kindRegex = ("(?i)" + kinds.mkString("(","|",")") + " (.*)").r + val restRegex = raw"(.*)".r + val escapedRegex = raw"`(.*)`".r + + def parse(query: String): List[Matchers] = query match { + case escapedRegex(rest) => List(Matchers.ByName(rest)) + case kindRegex(kind, rest) => List(Matchers.ByKind(kind)) ++ parse(rest) + case restRegex(name) => List(Matchers.ByName(name)) + case _ => List() + } \ No newline at end of file diff --git a/scala3doc-js/src/searchbar/engine/SearchbarEngine.scala b/scala3doc-js/src/searchbar/engine/SearchbarEngine.scala new file mode 100644 index 000000000000..d0398f233ae9 --- /dev/null +++ b/scala3doc-js/src/searchbar/engine/SearchbarEngine.scala @@ -0,0 +1,21 @@ +package dotty.dokka + +import math.Ordering.Implicits.seqOrdering + +class SearchbarEngine(pages: List[PageEntry]) { + def query(query: List[Matchers]): List[PageEntry] = { + pages + .map( page => + page -> query.map(matcher => matcher(page)) + ) + .filterNot { + case (page, matchResults) => matchResults.exists(_ < 0) + } + .sortBy { + case (page, matchResults) => matchResults + } + .map { + case (page, matchResults) => page + } + } +} \ No newline at end of file diff --git a/scala3doc/resources/dotty_res/styles/search-bar.css b/scala3doc/resources/dotty_res/styles/search-bar.css index 820dcd8f21ad..fc5c7ed45c15 100644 --- a/scala3doc/resources/dotty_res/styles/search-bar.css +++ b/scala3doc/resources/dotty_res/styles/search-bar.css @@ -9,20 +9,6 @@ background: none; } -/* button */ -.search button { - background: #ED3522; - fill: #fff; - cursor: pointer; - border: none; - padding: 9px; - border-radius: 24px; - box-shadow: 0 0 16px #F27264; -} -.search button:hover { - fill: #F27264; -} - /* popup */ .popup-wrapper { box-shadow: 0 0 10px var(--border-light) !important; @@ -58,18 +44,6 @@ /* Portrait phones */ @media(max-width: 576px) { - .search button { - background: none; - fill: var(--icon-color); - cursor: pointer; - border: none; - padding: 0; - box-shadow: none; - margin-top: 2px; - } - .search button:hover { - fill: var(--link-hover-fg); - } .search-content { margin: 0 !important; top: 9px !important; @@ -88,4 +62,4 @@ min-width: 100%; width: auto !important; } -} \ No newline at end of file +} diff --git a/scala3doc/src/dotty/dokka/preprocessors/ScalaEmbeddedResourceApppender.scala b/scala3doc/src/dotty/dokka/preprocessors/ScalaEmbeddedResourceApppender.scala index dc6801135340..eb5463a8b675 100644 --- a/scala3doc/src/dotty/dokka/preprocessors/ScalaEmbeddedResourceApppender.scala +++ b/scala3doc/src/dotty/dokka/preprocessors/ScalaEmbeddedResourceApppender.scala @@ -21,6 +21,7 @@ class ScalaEmbeddedResourceAppender extends PageTransformer { "styles/diagram.css", "styles/filter-bar.css", "styles/search-bar.css", + "styles/scala3doc-searchbar.css", "https://code.jquery.com/jquery-3.5.1.min.js", "https://d3js.org/d3.v6.min.js", "https://cdn.jsdelivr.net/npm/graphlib-dot@0.6.2/dist/graphlib-dot.min.js", @@ -36,7 +37,8 @@ class ScalaEmbeddedResourceAppender extends PageTransformer { "scripts/components/Input.js", "scripts/components/FilterGroup.js", "scripts/components/Filter.js", - "scripts/data.js" + "scripts/data.js", + "scripts/searchbar.js" )).asJava, page.getChildren ) diff --git a/scala3doc/src/dotty/dokka/translators/ScalaSignatureProvider.scala b/scala3doc/src/dotty/dokka/translators/ScalaSignatureProvider.scala index ec70415ea6c5..fc4865097f2c 100644 --- a/scala3doc/src/dotty/dokka/translators/ScalaSignatureProvider.scala +++ b/scala3doc/src/dotty/dokka/translators/ScalaSignatureProvider.scala @@ -48,7 +48,7 @@ object ScalaSignatureProvider: case tpe: Kind.Type => typeSignature(tpe, documentable, builder) case Kind.Package => - builder.text("package").text(" ").name(documentable.name, documentable.dri) + builder.text("package ").name(documentable.name, documentable.dri) case Kind.RootPackage => builder case Kind.Unknown => diff --git a/scala3doc/src/dotty/renderers/ScalaHtmlRenderer.scala b/scala3doc/src/dotty/renderers/ScalaHtmlRenderer.scala index 8112c04c3f88..15b4da14d5c3 100644 --- a/scala3doc/src/dotty/renderers/ScalaHtmlRenderer.scala +++ b/scala3doc/src/dotty/renderers/ScalaHtmlRenderer.scala @@ -279,7 +279,7 @@ class ScalaHtmlRenderer(using ctx: DokkaContext) extends HtmlRenderer(ctx) { div (id := "leftToggler")( span(cls := "icon-toggler") ), - div(id := "searchBar"), + div(id := "scala3doc-searchBar"), main( raw(buildWithKotlinx(kotlinxContent)) ), @@ -297,8 +297,7 @@ class ScalaHtmlRenderer(using ctx: DokkaContext) extends HtmlRenderer(ctx) { ) ) ), - script(`type` := "text/javascript", src := resolveRoot(page, "scripts/pages.js")), - script(`type` := "text/javascript", src := resolveRoot(page, "scripts/main.js")) + script(`type` := "text/javascript", src := resolveRoot(page, "scripts/pages.js")) ) ).toString diff --git a/scala3doc/src/dotty/renderers/ScalaSearchbarDataInstaller.scala b/scala3doc/src/dotty/renderers/ScalaSearchbarDataInstaller.scala index 9f654c00f053..3d0b1103b0d7 100644 --- a/scala3doc/src/dotty/renderers/ScalaSearchbarDataInstaller.scala +++ b/scala3doc/src/dotty/renderers/ScalaSearchbarDataInstaller.scala @@ -29,7 +29,14 @@ class ScalaSearchbarDataInstaller(val ctx: DokkaContext) extends SearchbarDataIn override def processPage(page: ContentPage, link: String) = Option(page.getDocumentable) match { - case Some(member) => processMember(member, link) + case Some(member) => { + // All members that don't have their own page + val all = member + .membersBy(m => m.kind != dotty.dokka.model.api.Kind.Package && !m.kind.isInstanceOf[Classlike]) + .filter(m => m.origin == Origin.RegularlyDefined && m.inheritedFrom.isEmpty) + all.foreach(processMember(_, link)) + processMember(member, link) + } case None => page match { case p: StaticPageNode => processStaticSite(p, link) case _ => () @@ -55,6 +62,6 @@ class ScalaSearchbarDataInstaller(val ctx: DokkaContext) extends SearchbarDataIn override def generatePagesList(): String = { val mapper = jacksonObjectMapper() - val pagesList = pages.values.map(p => createSearchRecord(p.signature, p.pkg, p.link, List(p.name).asJava)).toList.asJava + val pagesList = pages.values.map(p => createSearchRecord(p.signature, p.pkg, p.link, (List(p.name)).asJava)).toList.asJava mapper.writeValueAsString(pagesList) }