diff --git a/docs/_docs/internals/scaladoc/index.md b/docs/_docs/internals/scaladoc/index.md new file mode 100644 index 000000000000..d631c8c00a7c --- /dev/null +++ b/docs/_docs/internals/scaladoc/index.md @@ -0,0 +1,241 @@ +--- +layout: doc-page +title: Scaladoc +partof: scaladoc +overview-name: Scaladoc +num: 1 + +--- + + +# Custom Template Tags Documentation + + +## 1. Language Picker Tag + +The language_picker tag is a custom tag used to render a language selection dropdown on a page. This tag allows users to switch between different language versions of the content. The available languages are determined based on the configuration and the frontmatter of the page. + +### Prequisites: + - #### Configuration in config.yaml: + The available languages must be defined in the `config.yaml` file under the languages section. Each language should have a corresponding code and name. + Example: + ```yaml + languages: + - code: en + name: English + - code: ja + name: Japanese + ``` + - #### Language Folders in `_docs`: + For each language code specified in config.yaml, there should be a corresponding folder in the _docs directory. These folders contain the translated content for each language. + Example: + ``` + _docs/ + ├── en/ + ├── ja/ + + ``` + - #### Frontmatter in the page: + Each page should have a `languages` key in its frontmatter that lists the available languages for that page. This ensures that the language picker dropdown is rendered with the correct options. + + Example: + ```yaml + --- + title: Getting Started + languages: ['en', 'ja'] + --- + ``` + +### Usage: + +```html +
+ { % if page.languages % } + { % language_picker languages=page.languages % } + { % endif %} +
+``` + +### Considerations + +- Empty Language List: If the languages list in the frontmatter is empty, the language_picker tag will not render anything. +- Default Language: If the selected language is en, the language code will be removed from the URL, treating it as the default language. +- Folder Structure: Ensure that the corresponding language folders exist in the _docs directory; otherwise, the language switch will lead to broken links. + + + +## 1. Include Tag + +### Purpose: +The `{ % include % }` tag is used to include content from other files or templates within your page. This helps in reusing common elements across multiple pages, such as headers, footers, or any other repeated content. + +### Usage: +```html +{ % include 'filename' variable1='value1' variable2='value2' % } +``` + + +### Example: +```html +{ % include 'header.html' title='Welcome to My Website' % } +``` + +### How it Works: + - File Inclusion: The tag includes the specified file from a predefined folder. + - Passing Variables: You can pass variables when including a file, which can be accessed within the included file using the syntax {{ include.variable1 }}. + - Accessing Variables: Inside the included file, the passed variables can be accessed like regular variables, allowing dynamic content based on the context of inclusion. + + + +## 2. Tabs Block + +### Purpose: +The { % tabs % } block is used to group content into tabs, allowing users to switch between different content sections without reloading the page. This is useful for organizing content that has multiple views, like installation instructions for different platforms. + +### Usage: +```html +{ % tabs unique-tabs-id class='additional-class' % } + { % tab 'Tab 1' for='unique-tabs-id' % } + + { % endtab % } + + { % tab 'Tab 2' for='unique-tabs-id' % } + + { % endtab % } + +{ % endtabs % } +``` + +### Example: + +```html +{ % tabs install-instructions % } + { % tab 'Windows' for='install-instructions' % } + + { % endtab % } + + { % tab 'macOS' for='install-instructions' % } + + { % endtab % } + +{ % endtabs % } +``` + +How it Works: +- Tabs Block: The { % tabs % } tag creates a container for multiple tabs. +- Tab Content: Each { % tab % } tag defines the content of a single tab. The for attribute must match the id specified in the { % tabs % } block to correctly link the tab content. +- Default Tab: You can specify a default tab by using the defaultTab keyword in the { % tab % } tag. +- Styling: You can apply additional CSS classes to the tabs by specifying the class attribute + + +## 2. AltDetails Block + +### Purpose: +The { % altDetails % } tag is used to create switchable sections, often referred to as tabs. This is useful for including optional or advanced information that doesn’t need to be visible by default. + +### Usage: + +```html +{ % altDetails 'unique-id' 'Title of the Section' % } + +{ % endaltDetails %} + +``` + +### How it Works: + +- Collapsible Section: The { % altDetails % } tag creates a section that can be expanded or collapsed by the user. +- Title and ID: The first argument is a unique identifier for the section, and the second argument is the title displayed when the section is collapsed. +- Content: Any content placed within the { % altDetails % } and { % endaltDetails % } tags is hidden by default and can be revealed by clicking on the title. + + +# `_data` Folder + +The _data folder in your project is used to store YAML, JSON, or CSV files that contain structured data. These files can then be accessed throughout your site using the site.data variable. This is particularly useful for storing site-wide information, such as navigation menus, reusable content blocks, or any other structured data that needs to be referenced across multiple pages. + +### Structure and Access + - Folder Location: The _data folder should be located in the root directory of your project. + + - File Formats: The _data folder supports .yml, .yaml, .json, and .csv file formats. Each file represents a collection of data that can be accessed using the site.data variable. + + - Accessing Data: + - The data within a YAML file can be accessed using `site.data...` + - Replace `` with the name of the file (without the extension), and `` with the specific key within the file. + +### Example + +Suppose you have a YAML file `_data/navigation.yml` with the following content: + +```yaml +main_menu: + - name: Home + url: / + - name: About + url: /about/ + - name: Contact + url: /contact/ +``` + +You can access the data in this file using the following syntax: + +```html +
    + { % for item in site.data.navigation.main_menu % } +
  • { { item.name } }
  • + { % endfor % } +
+``` + +### Considerations + - File Naming: Make sure the file names are unique to avoid conflicts when accessing data. + - Data Structure: Keep the data structure consistent across different files to avoid confusion when accessing data in your templates. + + + + + + + +# Configuration File + +The `_config.yaml` file is a central configuration file located in the root directory of your project. It is used to define site-wide settings and variables that can be accessed throughout your site using the `site` variable. + +### Structure and Access + +- File Location: The _config.yaml file should be placed in the root directory of your project. + +- YAML Structure: The file uses YAML syntax to define configuration options. You can define any key-value pairs in this file, and they will be accessible via the site.config.`` variable in your templates. + +- Accessing Configuration: + - Any configuration setting defined in _config.yaml can be accessed using site.``. + - For example, if you define a key title: My Website, you can access it using site.title. + +### Example +If your _config.yaml includes: +```yaml +site_name: My Awesome Site +description: A description of my awesome site. +``` +You can access these values in your templates: +```html +{ { site.config.site_name } } +``` + + + + diff --git a/project/Build.scala b/project/Build.scala index 921fbcd80b90..bdcd71330365 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -1776,7 +1776,7 @@ object Build { bundleCSS.taskValue ), libraryDependencies ++= Dependencies.flexmarkDeps ++ Seq( - "nl.big-o" % "liqp" % "0.8.2", + "nl.big-o" % "liqp" % "0.9", "org.jsoup" % "jsoup" % "1.17.2", // Needed to process .html files for static site Dependencies.`jackson-dataformat-yaml`, diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 88a97721ee3c..d8e582de0afe 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -28,5 +28,8 @@ object Dependencies { "com.vladsch.flexmark" % "flexmark-ext-yaml-front-matter" % flexmarkVersion, ) + private val snakeYamlVersion = "2.2" + val `snakeyaml` = "org.yaml" % "snakeyaml" % snakeYamlVersion + val compilerInterface = "org.scala-sbt" % "compiler-interface" % "1.9.6" } diff --git a/project/ScaladocGeneration.scala b/project/ScaladocGeneration.scala index aac9f187a888..c744481b2e82 100644 --- a/project/ScaladocGeneration.scala +++ b/project/ScaladocGeneration.scala @@ -12,8 +12,9 @@ object ScaladocGeneration { def serialize: String = value match { case s: String => s"$key ${escape(s)}" - case true => s"$key" - case list: List[_] => s"$key:${list.map(x => escape(x.toString)).mkString(",")}" + case true => s"$key" + case list: List[_] => + s"$key:${list.map(x => escape(x.toString)).mkString(",")}" case _ => println(s"Unsupported setting: $key -> $value") "" @@ -141,10 +142,14 @@ object ScaladocGeneration { def key: String = "-dynamic-side-menu" } + case class ExperimentalFeatures(value: Boolean) extends Arg[Boolean] { + def key: String = "-use-experimental-features" + } + import _root_.scala.reflect._ trait GenerationConfig { - def get[T <: Arg[_] : ClassTag]: Option[T] + def get[T <: Arg[_]: ClassTag]: Option[T] def add[T <: Arg[_]](arg: T): GenerationConfig def remove[T <: Arg[_]: ClassTag]: GenerationConfig def withTargets(targets: Seq[String]): GenerationConfig @@ -154,43 +159,50 @@ object ScaladocGeneration { object GenerationConfig { def apply(): GenerationConfig = GenerationConfigImpl(Seq.empty, Seq.empty) - def apply(targets: List[String], args: Arg[_]*): GenerationConfig = args.foldLeft(GenerationConfig()) { (config, elem) => - config.add(elem) - } - private case class GenerationConfigImpl(targets: Seq[String], args: Seq[Arg[_]]) extends GenerationConfig { + def apply(targets: List[String], args: Arg[_]*): GenerationConfig = + args.foldLeft(GenerationConfig()) { (config, elem) => + config.add(elem) + } + private case class GenerationConfigImpl( + targets: Seq[String], + args: Seq[Arg[_]] + ) extends GenerationConfig { override def add[T <: Arg[_]](arg: T): GenerationConfig = { implicit val tag: ClassTag[T] = ClassTag(arg.getClass) val (removedElem, argsWithoutElem) = argsWithout[T](tag) - removedElem.foreach(elem => println(s"$elem has been overwritten by $arg")) + removedElem.foreach(elem => + println(s"$elem has been overwritten by $arg") + ) GenerationConfigImpl(targets, argsWithoutElem :+ arg) } - override def remove[T <: Arg[_] : ClassTag]: GenerationConfig = { + override def remove[T <: Arg[_]: ClassTag]: GenerationConfig = { GenerationConfigImpl(targets, argsWithout[T]._2) } - override def get[T <: Arg[_] : ClassTag]: Option[T] = { + override def get[T <: Arg[_]: ClassTag]: Option[T] = { val tag = implicitly[ClassTag[T]] - args.collect { - case tag(t) => t + args.collect { case tag(t) => + t }.headOption } override def withTargets(targets: Seq[String]) = copy(targets = targets) override def serialize: String = ( args - .map(_.serialize) - ++ targets + .map(_.serialize) + ++ targets ).mkString(" ") override def settings: Seq[String] = args.map(_.serialize) ++ targets - private def argsWithout[T <: Arg[_]]( - implicit tag: ClassTag[T] - ): (Option[T], Seq[Arg[_]]) = args.foldLeft[(Option[T], Seq[Arg[_]])]((None, Seq.empty)) { - case ((removedElem, rest), tag(t)) => (Some(t), rest) - case ((removedElem, rest), elem) => (removedElem, rest :+ elem) - } + private def argsWithout[T <: Arg[_]](implicit + tag: ClassTag[T] + ): (Option[T], Seq[Arg[_]]) = + args.foldLeft[(Option[T], Seq[Arg[_]])]((None, Seq.empty)) { + case ((removedElem, rest), tag(t)) => (Some(t), rest) + case ((removedElem, rest), elem) => (removedElem, rest :+ elem) + } } } diff --git a/scaladoc/resources/dotty_res/scripts/staticsite/alt-details.js b/scaladoc/resources/dotty_res/scripts/staticsite/alt-details.js new file mode 100644 index 000000000000..43b8b93d1b12 --- /dev/null +++ b/scaladoc/resources/dotty_res/scripts/staticsite/alt-details.js @@ -0,0 +1,18 @@ +// Get all the alt-details-control elements +const altDetailsControls = document.querySelectorAll('.alt-details-control'); + +// Loop through each control element +altDetailsControls.forEach(control => { + // Add event listener for 'change' event + control.addEventListener('change', function() { + // Get the corresponding alt-details-detail element + const detailElement = this.nextElementSibling.nextElementSibling; + + // Toggle the display of the detail element based on checkbox state + if (this.checked) { + detailElement.style.display = 'block'; + } else { + detailElement.style.display = 'none'; + } + }); +}); diff --git a/scaladoc/resources/dotty_res/scripts/staticsite/main.js b/scaladoc/resources/dotty_res/scripts/staticsite/main.js new file mode 100644 index 000000000000..6a47a8cf0532 --- /dev/null +++ b/scaladoc/resources/dotty_res/scripts/staticsite/main.js @@ -0,0 +1,77 @@ +document.addEventListener("DOMContentLoaded", function () { + const tabContainers = document.querySelectorAll('.tabs'); + tabContainers.forEach(container => { + const radios = container.querySelectorAll('.tab-radio'); + const labels = container.querySelectorAll('.tab-label'); + const contents = container.querySelectorAll('.tab-content'); + + // Hide all tab contents except the first + contents.forEach((content, index) => { + if (index !== 0) content.style.display = 'none'; + }); + + // Check the first radio button + if (radios.length > 0) radios[0].checked = true; + contents[0].style.display = 'block'; // Ensure the first tab content is displayed + + labels.forEach((label, index) => { + label.addEventListener('click', () => { + // Hide all tab contents + contents.forEach(content => content.style.display = 'none'); + + // Show the clicked tab's content + contents[index].style.display = 'block'; + }); + }); + }); +}); + + +function handleLanguageChange(selectElement) { + console.log("This Function Works"); + + var selectedLanguage = selectElement.value; + var currentUrl = window.location.href; + var urlParts = currentUrl.split('/'); + var baseUrl = urlParts.slice(0, 3).join('/'); + var pathParts = urlParts.slice(3); + + // Identify the index of the 'docs' path + var docsIndex = pathParts.indexOf('docs'); + + // Regex to match a language code right after the 'docs' path + var languagePattern = new RegExp('^(' + availableLanguages.join('|') + ')(-[a-z]{2})?'); + var currentLangCode = docsIndex >= 0 && pathParts.length > docsIndex + 1 ? pathParts[docsIndex + 1].match(languagePattern) : null; + + if (selectedLanguage) { + var updatedPath; + + if (selectedLanguage == 'en') { + // If 'en' is selected, remove the language code if it exists after 'docs' + if (currentLangCode && availableLanguages.includes(currentLangCode[0])) { + updatedPath = pathParts.slice(0, docsIndex + 1).concat(pathParts.slice(docsIndex + 2)); // Remove the language code after 'docs' + } else { + updatedPath = pathParts; + } + } else { + // If any other language is selected + if (currentLangCode && availableLanguages.includes(currentLangCode[0])) { + // Replace the existing language code with the new one after 'docs' + updatedPath = pathParts.slice(0, docsIndex + 1).concat([selectedLanguage]).concat(pathParts.slice(docsIndex + 2)); + } else { + // Add the new language code after the 'docs' path + updatedPath = pathParts.slice(0, docsIndex + 1).concat([selectedLanguage]).concat(pathParts.slice(docsIndex + 1)); + } + } + + // Handle edge case where updatedPath might be empty + if (updatedPath.length === 0) { + window.location.href = baseUrl; + } else { + window.location.href = baseUrl + '/' + updatedPath.join('/'); + } + } else { + // If no language is selected, keep the path unchanged + window.location.href = baseUrl + '/' + pathParts.join('/'); + } +} diff --git a/scaladoc/resources/dotty_res/styles/staticsitestyles.css b/scaladoc/resources/dotty_res/styles/staticsitestyles.css index e69de29bb2d1..8d2b29d69368 100644 --- a/scaladoc/resources/dotty_res/styles/staticsitestyles.css +++ b/scaladoc/resources/dotty_res/styles/staticsitestyles.css @@ -0,0 +1,134 @@ +.place-inline { + padding: 10px; + margin: 20px 0; +} + + +.alt-details.help-info .alt-details-toggle { + width: 100%; + background-color: #007bff; + + color: white; +} + +.alt-details.help-info .alt-details-toggle:hover { + background-color: #0056b3; + +} + +.alt-details.help-info .alt-details-detail { + background: #fae6e6; +} + +.alt-details { + width: 100%; + display: flex; + justify-items: center; + align-items: center; + flex-direction: column; +} + + +.alt-details-toggle { + width: 100%; + + font-family: Arial, sans-serif; + + line-height: normal; + text-align: center; + border: none; + background-color: #6c757d; + + padding: 5px 10px; + border-radius: 0.3rem; + + font-size: 1rem; + + cursor: pointer; + font-weight: 500; + color: #343a40; +} + +.alt-details-toggle:hover { + background-color: #545b62; + +} + +.alt-details-toggle:after { + content: "\f138"; + font-family: "Font Awesome 5 Free"; + font-weight: 900; + font-size: 15px; + float: right; + margin-top: 2px; +} + +.alt-details-control { + margin: 0; +} + +.alt-details-control+.alt-details-toggle+.alt-details-detail { + position: absolute; + top: -999em; + left: -999em; +} + +.alt-details-control:checked+.alt-details-toggle+.alt-details-detail { + position: static; +} + +.alt-details-control:checked+.alt-details-toggle:after { + content: "\f13a"; + +} + +.alt-details-detail { + border-bottom: 1px solid #ddd; + + border-left: 1px solid #ddd; + + border-right: 1px solid #ddd; + + padding-top: 15px; + margin-top: 15px; +} + +/* General Tabs Styles */ +.tabs { + width: 100%; + margin: 0 auto; +} + +/* Tab Radio Buttons (Hidden) */ +.tab-radio { + display: none; +} + +/* Tab Labels */ +.tab-label { + display: inline-block; + padding: 10px 20px; + margin: 0; + cursor: pointer; + background-color: #f1f1f1; + border: 1px solid #ddd; + border-bottom: none; + transition: background-color 0.3s ease-in-out; +} + +.tab-label:hover { + background-color: #ddd; +} + +/* Tab Contents */ +.tab-content { + display: none; + padding: 20px; + border: 1px solid #ddd; + border-top: none; +} + +/* Show the content of the selected tab */ +.tab-radio:checked+.tab-label+.tab-content { + display: block; +} diff --git a/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala b/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala index a2485085a927..c5862fcb728a 100644 --- a/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala +++ b/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala @@ -47,6 +47,7 @@ object Scaladoc: defaultTemplate: Option[String] = None, quickLinks: List[QuickLink] = List.empty, dynamicSideMenu: Boolean = false, + experimentalFeatures: Boolean = false, ) def run(args: Array[String], rootContext: CompilerContext): Reporter = @@ -67,6 +68,12 @@ object Scaladoc: if (parsedArgs.output.exists()) util.IO.delete(parsedArgs.output) + // TODO: Activate the new method to genarate only the static site + // if parsedArgs.staticSiteOnly then + // generateStaticSite(updatedArgs) // New method to generate only static site + // else + // run(updatedArgs) + run(updatedArgs) report.inform("Done") else report.error("Failure") @@ -197,6 +204,9 @@ object Scaladoc: if deprecatedSkipPackages.get.nonEmpty then report.warning(deprecatedSkipPackages.description) + val experimentalFeatures = args.contains("-use-experimental-features") + + val docArgs = Args( projectName.withDefault("root"), dirs, @@ -231,6 +241,7 @@ object Scaladoc: defaultTemplate.nonDefault, quickLinksParsed, dynamicSideMenu.get, + experimentalFeatures ) (Some(docArgs), newContext) } diff --git a/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala b/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala index 5acfac03d52c..910c8482df7b 100644 --- a/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala +++ b/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala @@ -143,6 +143,8 @@ class ScaladocSettings extends SettingGroup with AllScalaSettings: val dynamicSideMenu: Setting[Boolean] = BooleanSetting(RootSetting, "dynamic-side-menu", "Generate side menu via JS instead of embedding it in every html file", false) + val experimentalFeatures: Setting[Boolean] = + BooleanSetting(RootSetting, "use-experimental-features", "Activate Experimental Features for scaladoc", false) def scaladocSpecificSettings: Set[Setting[?]] = - Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks, dynamicSideMenu) + Set(sourceLinks, legacySourceLink, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, generateInkuire, defaultTemplate, scastieConfiguration, quickLinks, dynamicSideMenu,experimentalFeatures) diff --git a/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala b/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala index 3e49af2e0576..5219152b4073 100644 --- a/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala +++ b/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala @@ -104,6 +104,8 @@ trait Resources(using ctx: DocContext) extends Locations, Writer: "styles/versions-dropdown.css", "styles/content-contributors.css", "styles/fontawesome.css", + "styles/staticsitestyles.css", + "scripts/staticsite/alt-details.js", "hljs/highlight.pack.js", "hljs/LICENSE", "scripts/hljs-scala3.js", @@ -117,6 +119,8 @@ trait Resources(using ctx: DocContext) extends Locations, Writer: "scripts/components/Filter.js", "scripts/scaladoc-scalajs.js", "scripts/contributors.js", + "scripts/staticsite/main.js" + ).map(dottyRes) val urls = List( @@ -135,7 +139,8 @@ trait Resources(using ctx: DocContext) extends Locations, Writer: ).map(dottyRes) val staticSiteOnlyResources = List( - "styles/staticsitestyles.css" + // "styles/staticsitestyles.css", + ).map(dottyRes) val searchDataPath = "scripts/searchData.js" diff --git a/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala b/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala index a610e41f12f0..410a64855a8f 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala @@ -7,9 +7,9 @@ import java.nio.file.FileVisitOption import java.nio.file.Path import java.nio.file.Paths -import scala.jdk.CollectionConverters._ import scala.util.control.NonFatal + class StaticSiteContext( val root: File, val args: Scaladoc.Args, @@ -42,10 +42,15 @@ class StaticSiteContext( lazy val staticSiteRoot: StaticSiteRoot = StaticSiteLoader(root, args)(using this, outerCtx).load() + + lazy val allTemplates = def process(l: LoadedTemplate): List[LoadedTemplate] = l +: l.children.flatMap(process) process(staticSiteRoot.rootTemplate) + + + /** Handles redirecting from multiple locations to one page * * For each entry in redirectFrom setting, create a page which contains code that redirects you to the page where the redirectFrom is defined. @@ -116,6 +121,8 @@ class StaticSiteContext( def pathFromRoot(myTemplate: LoadedTemplate) = root.toPath.relativize(myTemplate.file.toPath) + val projectWideProperties = Seq("projectName" -> args.name) ++ args.projectVersion.map("projectVersion" -> _) + diff --git a/scaladoc/src/dotty/tools/scaladoc/site/blocks/AltDetails.scala b/scaladoc/src/dotty/tools/scaladoc/site/blocks/AltDetails.scala new file mode 100644 index 000000000000..30fece7ee0c0 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/site/blocks/AltDetails.scala @@ -0,0 +1,61 @@ +package dotty.tools.scaladoc.site.blocks + +import liqp.TemplateContext +import liqp.nodes.LNode +import liqp.TemplateContext +import liqp.nodes.LNode + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.{lang, util} +import java.util.Map as JMap +import java.util.HashMap + + +class AltDetails extends liqp.blocks.Block("altDetails") { + def render(context: TemplateContext, nodes: LNode*): Any = { + + // Ensure the block content is the last node + val block = nodes.last + val inputString = nodes.dropRight(1).map(_.render(context).toString).mkString + + val (id, title, cssClass) = extractInfo(inputString) + + + // Render the block content + val blockContent = block.render(context).toString + + // Build the HTML string using StringBuilder + val builder = new StringBuilder + builder.append( + s""" + |
+ |
+ | + | + |
+ |
+ | $blockContent
+ |
+ |
+ """.stripMargin) + + // Return the final rendered string + builder.toString + } + + private def extractInfo(inputString: String): (String, String, String) = { + val pattern = """^([^']+)'(.*?)'(?:class=([\w\-]+))?$""".r + + val matchResult = pattern.findFirstMatchIn(inputString) + if (matchResult.isEmpty) { + return ("", "", "") + } + + val id = matchResult.get.group(1) + val title = matchResult.get.group(2) + val cssClass = matchResult.get.group(3) + + (id, title, cssClass) + } +} diff --git a/scaladoc/src/dotty/tools/scaladoc/site/blocks/Tabs.scala b/scaladoc/src/dotty/tools/scaladoc/site/blocks/Tabs.scala new file mode 100644 index 000000000000..d0351e022368 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/site/blocks/Tabs.scala @@ -0,0 +1,89 @@ +package dotty.tools.scaladoc.site.blocks + + +import liqp.TemplateContext +import liqp.nodes.LNode +import liqp.blocks.Block +import scala.collection.mutable.ListBuffer +import java.util.UUID + +class TabsBlock extends Block("tabs") { + override def render(context: TemplateContext, nodes: LNode*): AnyRef = { + // Generate a unique ID for this set of tabs + val uniqueId = UUID.randomUUID().toString + + // Add the unique ID to the context + context.put("tabs-unique-id", uniqueId) + + // Render the content of the tabs block + nodes.foreach(_.render(context)) + + // Extract tab headers and contents from the context + val tabHeaders = context.remove(s"tab-headers-$uniqueId").asInstanceOf[ListBuffer[String]].mkString("\n") + val tabContents = context.remove(s"tab-contents-$uniqueId").asInstanceOf[ListBuffer[String]].mkString("\n") + + // Build the HTML string using StringBuilder + val builder = new StringBuilder + builder.append( + s""" + |
+ | $tabHeaders + | $tabContents + |
+ |""".stripMargin) + + // Return the final rendered string + builder.toString + } +} + +class TabBlock extends Block("tab") { + override def render(context: TemplateContext, nodes: LNode*): AnyRef = { + val inputString = nodes.head.render(context).toString + + val pattern = """(.*?)(?:for=(.*))?""".r + + val (tabName, forValue) = inputString match { + case pattern(tab, forPart) => (tab, Option(forPart).getOrElse("")) + case _ => ("", "") + } + + + val content = nodes.tail.map(_.render(context).toString).mkString + + // Retrieve the unique ID from the context + val uniqueId = context.get("tabs-unique-id").toString + + val header = + s""" + | + | + |""".stripMargin + val tabContent = + s""" + |
+ | $content + |
+ |""".stripMargin + + // Add the header and content to the context + context.get(s"tab-headers-$uniqueId") match { + case headers: ListBuffer[String] @unchecked => + headers += header + case null => + val newHeaders = ListBuffer[String]() + newHeaders += header + context.put(s"tab-headers-$uniqueId", newHeaders) + } + val contents = context.get(s"tab-contents-$uniqueId") + if (contents.isInstanceOf[ListBuffer[?]]) { + val contentsList = contents.asInstanceOf[ListBuffer[String]] + contentsList += tabContent + } else { + val newContents = ListBuffer[String]() + newContents += tabContent + context.put(s"tab-contents-$uniqueId", newContents) + } + "" // Return empty string + } +} diff --git a/scaladoc/src/dotty/tools/scaladoc/site/common.scala b/scaladoc/src/dotty/tools/scaladoc/site/common.scala index 9e58dbe3cd28..07d35eea4a9b 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/common.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/common.scala @@ -20,6 +20,9 @@ import scala.jdk.CollectionConverters._ import com.vladsch.flexmark.util.data.DataHolder import com.vladsch.flexmark.util.data.MutableDataSet +import org.yaml.snakeyaml.Yaml + + val docsRootDRI: DRI = DRI(location = "_docs/index", symbolUUID = staticFileSymbolUUID) val apiPageDRI: DRI = DRI(location = "api/index") @@ -75,17 +78,33 @@ def loadTemplateFile(file: File, defaultTitle: Option[TemplateName] = None)(usin (lines.tail, Nil) } else (Nil, lines) - val configParsed = yamlParser.parse(config.mkString(LineSeparator)) + val yamlString = config.mkString(LineSeparator) + val configParsed = yamlParser.parse(yamlString) val yamlCollector = new AbstractYamlFrontMatterVisitor() yamlCollector.visit(configParsed) + val cleanedYamlString = yamlString + .stripPrefix(ConfigSeparator) + .stripSuffix(ConfigSeparator) + val yaml = new Yaml() + val parsedYaml: java.util.LinkedHashMap[String, Object]= if (cleanedYamlString.trim.isEmpty) null else yaml.load(cleanedYamlString).asInstanceOf[java.util.LinkedHashMap[String, Object]] + + + + + def getSettingValue(k: String, v: JList[String]): String | List[String] = if v.size == 1 then v.get(0) else v.asScala.toList val globalKeys = Set("extraJS", "extraCSS", "layout", "hasFrame", "name", "title") val allSettings = yamlCollector.getData.asScala.toMap.transform(getSettingValue) val (global, inner) = allSettings.partition((k,_) => globalKeys.contains(k)) - val settings = Map("page" -> inner) ++ global + val settings = if (parsedYaml == null) { + Map("page" -> inner) ++ global + } else { + Map("page" -> parsedYaml.asScala.toMap) ++ global + } + def stringSetting(settings: Map[String, Object], name: String): Option[String] = settings.get(name).map { case List(elem: String) => elem diff --git a/scaladoc/src/dotty/tools/scaladoc/site/customTags/IncludeTag.scala b/scaladoc/src/dotty/tools/scaladoc/site/customTags/IncludeTag.scala new file mode 100644 index 000000000000..25f3d1ac74ce --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/site/customTags/IncludeTag.scala @@ -0,0 +1,88 @@ +package dotty.tools.scaladoc.site.tags + + +import liqp.TemplateContext +import liqp.nodes.LNode +import liqp.tags.Tag +import scala.io.Source +import java.io.File +import scala.util.{Try, Success, Failure} +import scala.jdk.CollectionConverters._ + +class IncludeTag extends Tag("include") { + + override def render(context: TemplateContext, args: Array[? <: LNode]): Object = { + // Render arguments and validate filename + val filename = args(0).render(context) match { + case s: String if s.endsWith(".html") => s + case s: String => throw new IllegalArgumentException(s"{% include %} tag requires a string argument ending with .html, but got: $s") + case _ => throw new IllegalArgumentException("{% include %} tag requires a string argument ending with .html") + } + + val filePath = s"${IncludeTag.docsFolder}/$filename" + println(s"Attempting to include file: $filePath") + + val inputData = args(1).render(context) + context.put("include", inputData) + + try { + // Read and parse the file content + val fileContent = readFileContent(filePath) match { + case Success(content) => content + case Failure(exception) => throw new IllegalArgumentException(s"Error reading file '$filePath': ${exception.getMessage}") + } + + // Resolve nested includes within the file content + val resolvedContent = resolveNestedIncludes(fileContent, context) + + val siteData = context.getVariables().asInstanceOf[java.util.Map[String, Any]] + // Parse the resolved content + context.getParser().parse(resolvedContent).render(siteData) + } finally { + // Clean up: remove the input data from the context + context.remove("include") + } + } + + private def readFileContent(filePath: String): Try[String] = { + Try { + val file = new File(filePath) + println(s"Resolved file path: ${file.getAbsolutePath}") + if (!file.exists()) { + throw new IllegalArgumentException(s"File '$filePath' does not exist") + } + val source = Source.fromFile(file) + try source.mkString finally source.close() + } + } + + private def resolveNestedIncludes(content: String, context: TemplateContext): String = { + // Use a regex to find all {% include %} tags in the content + val includeTagPattern = "\\{%\\s*include\\s+\"([^\"]+)\"\\s*%\\}".r + + includeTagPattern.replaceAllIn(content, { matchData => + val includeFilename = matchData.group(1) + val nestedFilePath = s"${IncludeTag.docsFolder}/$includeFilename" + + // Read and parse the nested file content + val nestedFileContent = readFileContent(nestedFilePath) match { + case Success(content) => content + case Failure(exception) => throw new IllegalArgumentException(s"Error reading file '$nestedFilePath': ${exception.getMessage}") + } + + // Recursively resolve any further nested includes within the nested file content + val tempContext = new TemplateContext(context.getParser(), context.getVariables()) + resolveNestedIncludes(nestedFileContent, tempContext) + }) + } +} + +object IncludeTag { + @volatile private var docsFolder: String = "_docs" + + def setDocsFolder(path: String): Unit = { + docsFolder = path + } + + def getDocsFolder: String = docsFolder +} diff --git a/scaladoc/src/dotty/tools/scaladoc/site/customTags/LanguagePicker.scala b/scaladoc/src/dotty/tools/scaladoc/site/customTags/LanguagePicker.scala new file mode 100644 index 000000000000..f3496d503d5b --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/site/customTags/LanguagePicker.scala @@ -0,0 +1,148 @@ +package dotty.tools.scaladoc.site.tags + +import java.nio.file.Paths +import scala.jdk.CollectionConverters._ +import liqp.TemplateContext +import liqp.nodes.LNode +import liqp.tags.Tag +import dotty.tools.scaladoc.site.helpers.ConfigLoader +import dotty.tools.scaladoc.site.helpers.Config + +class LanguagePickerTag extends Tag("language_picker") { + + override def render( + context: TemplateContext, + args: Array[? <: LNode] + ): Object = { + if (args.isEmpty || args(0) == null) { + throw new IllegalArgumentException( + "The 'languages' argument is required and must be provided in the {% language_picker %} tag." + ) + } + + // Raw argument value + val rawArgument = + args(0).render(context).toString.trim.stripPrefix("languages=") + + // Determine if the argument is a variable reference or a literal string + val (languagesArg, valueType) = if (rawArgument.startsWith("page.")) { + // Extract the variable name and fetch its value from the context + val variableName = rawArgument.stripPrefix("page.") + val siteData = context + .getVariables() + .get("page") + .asInstanceOf[java.util.Map[String, Any]] + + // Handle both String and List cases + siteData.get(variableName) match { + case value: String => + (List(value), "String") + case value: java.util.List[_] => + (value.asScala.toList.collect { case s: String => s }, "List") + case value => + ( + List.empty[String], + if (value != null) value.getClass.getSimpleName else "null" + ) + } + } else { + // Treat the argument as a literal string and split it by comma if necessary + (rawArgument.split(",").map(_.trim).toList, "Literal String") + } + + // Ensure "en" is the first element in the list + val languagesWithEnFirst = { + if (languagesArg.contains("en")) { + // If "en" is already in the list, move it to the first position + "en" :: languagesArg.filterNot(_ == "en") + } else { + // Otherwise, prepend "en" to the list + "en" :: languagesArg + } + } + + // Early return if the languages argument is empty + if (languagesWithEnFirst.isEmpty) { + println("Languages argument is empty, not rendering anything.") + return "" + } + + // Extract language codes from the list + val extractedLanguageCodes = + languagesWithEnFirst.flatMap(extractLanguageCodes) + + // Load the config using ConfigLoader + val config = LanguagePickerTag.getConfigValue + + // Access languages from the config + val languagesOpt = + config.get[java.util.List[java.util.Map[String, String]]]("languages") + val languagesFromConfig = languagesOpt match { + case Some(list) => list.asScala.toList.map(_.asScala.toMap) + case None => + throw new IllegalArgumentException( + "No languages found in configuration" + ) + } + + // Extract the list of codes from the config + val configLanguageCodes = languagesFromConfig.map(_("code")) + + // Validate that all languages in extractedLanguageCodes exist in the config + val missingLanguages = + extractedLanguageCodes.filterNot(configLanguageCodes.contains) + if (missingLanguages.nonEmpty) { + throw new IllegalArgumentException( + s"The following languages are not defined in the configuration: ${missingLanguages.mkString(", ")}" + ) + } + + // Filter the languages from the config based on the extractedLanguageCodes + val languages = languagesFromConfig.filter(language => + extractedLanguageCodes.contains(language("code")) + ) + + // Create the dropdown HTML + val dropdown = new StringBuilder + dropdown.append( + " " + ) + + dropdown.toString() + } + + private def extractLanguageCodes(languagesArg: String): List[String] = { + // Regular expression to match both two-letter codes and hyphenated parts + val pattern = """[a-z]{2}(-[a-z]{2})?""".r + + // Find all matches in the string + val matches = pattern.findAllIn(languagesArg).toList + + // Ensure that each part is valid and non-empty + matches.map(_.trim).filter(_.nonEmpty) + } +} + +object LanguagePickerTag { + @volatile private var config: Config = null + + def setConfigValue(configuration: Config): Unit = { + config = configuration + } + + def getConfigValue: Config = config +} diff --git a/scaladoc/src/dotty/tools/scaladoc/site/helpers/ConfigLoader.scala b/scaladoc/src/dotty/tools/scaladoc/site/helpers/ConfigLoader.scala new file mode 100644 index 000000000000..f10918cccaf3 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/site/helpers/ConfigLoader.scala @@ -0,0 +1,108 @@ +package dotty.tools.scaladoc.site.helpers + +import java.io.{File, InputStream} +import java.nio.file.{Files, Paths, Path} +import scala.jdk.CollectionConverters._ +import scala.collection.mutable +import scala.language.dynamics +import scala.language.dynamics +import scala.language.dynamics +import liqp.TemplateContext +import liqp.nodes.LNode +import liqp.tags.Tag +import org.yaml.snakeyaml.Yaml + +class Config(private val data: mutable.LinkedHashMap[String, Any]) + extends Dynamic { + + def selectDynamic(field: String): Any = { + data.get(field) match { + case Some(value: mutable.LinkedHashMap[_, _]) => + new Config(value.asInstanceOf[mutable.LinkedHashMap[String, Any]]) + case Some(value: java.util.ArrayList[_]) => + value.asScala.toSeq.map { + case map: java.util.Map[_, _] => + new Config( + map + .asInstanceOf[java.util.Map[String, Any]] + .asScala + .to(mutable.LinkedHashMap) + ) + case other => other + } + case Some(value) => value + case None => throw new NoSuchElementException(s"No such element: $field") + } + } + + def get[T](key: String): Option[T] = { + data.get(key).map(_.asInstanceOf[T]) + } + + // method to convert the internal map to a Java map + def convertToJava: java.util.Map[String, Any] = { + data.map { + case (key, value: Config) => key -> value.convertToJava + case (key, value: mutable.LinkedHashMap[_, _]) => + key -> value.asInstanceOf[mutable.LinkedHashMap[String, Any]].asJava + case (key, value) => key -> value + }.asJava + } +} + +class ConfigLoader { + def loadConfig(basePath: String): Config = { + val configMap = mutable.LinkedHashMap[String, Any]() + val yamlFileNames = Seq("_config.yml", "_config.yaml") + + try { + val yaml = new Yaml() + val baseDir = new File(basePath).getAbsolutePath + + val configFile = yamlFileNames + .map(baseDir + File.separator + _) + .map(Paths.get(_)) + .find(Files.exists(_)) + + configFile match { + case Some(path) => + val inputStream: InputStream = Files.newInputStream(path) + val data = yaml.load[java.util.Map[String, Any]](inputStream) + configMap ++= data.asScala.toMap + + // Check for language folders + val languagesOpt = + configMap.get("languages").collect { case list: java.util.List[_] => + list.asScala.toList.collect { case map: java.util.Map[_, _] => + map.asScala.toMap.collect { case (key: String, value: String) => + key -> value + } + } + } + + // languagesOpt match { + // case Some(languages) => + // for (language <- languages) { + // // Cast the key to String to avoid type mismatch errors + // val languageCode = language("code".asInstanceOf[language.K]).asInstanceOf[String] + // val languageFolderPath = Paths.get(baseDir, languageCode) + // if (!Files.exists(languageFolderPath) || !Files.isDirectory(languageFolderPath)) { + // throw new IllegalArgumentException(s"Language folder for '$languageCode' does not exist at path: $languageFolderPath") + // } + // } + // case None => + // println(s"Warning: No languages found in configuration.") + // } + + case None => + println(s"Warning: No config file found in path: $baseDir") + } + } catch { + case e: Exception => + println(s"Error loading config file: ${e.getMessage}") + throw e + } + + new Config(configMap) + } +} diff --git a/scaladoc/src/dotty/tools/scaladoc/site/helpers/DataLoader.scala b/scaladoc/src/dotty/tools/scaladoc/site/helpers/DataLoader.scala new file mode 100644 index 000000000000..366dd595259d --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/site/helpers/DataLoader.scala @@ -0,0 +1,63 @@ +package dotty.tools.scaladoc.site.helpers + +import java.io.File +import org.yaml.snakeyaml.Yaml +import scala.io.Source +import scala.jdk.CollectionConverters._ +import java.util.{LinkedHashMap, List, Map} + +class DataLoader { + + def loadDataDirectory(directoryPath: String): LinkedHashMap[String, Object] = { + val dataMap = new LinkedHashMap[String, Object]() + try { + loadDirectory(new File(directoryPath), dataMap) + } catch { + case e: Exception => + println(s"Error loading data directory: ${e.getMessage}") + } + dataMap + } + + private def loadDirectory(directory: File, dataMap: LinkedHashMap[String, Object]): Unit = { + if (directory.exists() && directory.isDirectory) { + for (file <- directory.listFiles()) { + if (file.isDirectory) { + loadDirectory(file, dataMap) + } else { + readYamlFile(file, dataMap) + } + } + } else { + println(s"Directory does not exist or is not a directory: ${directory.getPath}") + } + } + + private def readYamlFile(file: File, dataMap: LinkedHashMap[String, Object]): Unit = { + try { + val yaml = new Yaml() + val yamlContents: Object = convertToJava(yaml.load(Source.fromFile(file).mkString)) + val fileName = file.getName.stripSuffix(".yaml").stripSuffix(".yml") + + dataMap.put(fileName, yamlContents) + } catch { + case e: Exception => + println(s"Error reading YAML file '${file.getName}': ${e.getMessage}") + } + } + + private def convertToJava(value: Any): Object = { + value match { + case map: java.util.Map[_, _] => + val javaMap = new LinkedHashMap[String, Object]() + map.asScala.foreach { case (k, v) => javaMap.put(k.toString, convertToJava(v)) } + javaMap.asInstanceOf[Object] + case list: java.util.List[_] => + val javaList = new java.util.ArrayList[Object]() + list.asScala.foreach(v => javaList.add(convertToJava(v))) + javaList.asInstanceOf[Object] + case other => + other.asInstanceOf[Object] + } + } +} diff --git a/scaladoc/src/dotty/tools/scaladoc/site/templates.scala b/scaladoc/src/dotty/tools/scaladoc/site/templates.scala index c37ff8fe0200..d9264d941a0a 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/templates.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/templates.scala @@ -3,7 +3,7 @@ package site import java.io.File import java.nio.file.{Files, Paths} - +import java.util.{HashMap => JavaHashMap} import com.vladsch.flexmark.ext.autolink.AutolinkExtension import com.vladsch.flexmark.ext.emoji.EmojiExtension import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension @@ -13,17 +13,26 @@ import com.vladsch.flexmark.ext.yaml.front.matter.{AbstractYamlFrontMatterVisito import com.vladsch.flexmark.parser.{Parser, ParserEmulationProfile} import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.formatter.Formatter +import dotty.tools.scaladoc.site.helpers.DataLoader import liqp.Template -import liqp.ParseSettings +import liqp.TemplateParser import liqp.parser.Flavor import liqp.TemplateContext import liqp.tags.Tag import liqp.nodes.LNode -import scala.jdk.CollectionConverters._ +import scala.collection.mutable +import dotty.tools.scaladoc.site.blocks.{AltDetails,TabsBlock,TabBlock} +import dotty.tools.scaladoc.site.tags.{IncludeTag,LanguagePickerTag} +import dotty.tools.scaladoc.site.helpers.{ConfigLoader=>SiteConfigLoader} + + + +import scala.jdk.CollectionConverters.* import scala.io.Source -import dotty.tools.scaladoc.snippets._ -import scala.util.chaining._ +import dotty.tools.scaladoc.snippets.* + +import scala.util.chaining.* /** RenderingContext stores information about defined properties, layouts and sites being resolved * @@ -79,6 +88,7 @@ case class TemplateFile( lazy val snippetCheckingFunc: SnippetChecker.SnippetCheckingFunc = val path = Some(Paths.get(file.getAbsolutePath)) + val pathBasedArg = ssctx.snippetCompilerArgs.get(path) val sourceFile = dotty.tools.dotc.util.SourceFile(dotty.tools.io.AbstractFile.getFile(path.get), scala.io.Codec.UTF8) (str: String, lineOffset: SnippetChecker.LineOffset, argOverride: Option[SCFlags]) => { @@ -98,6 +108,7 @@ case class TemplateFile( if (ctx.resolving.contains(file.getAbsolutePath)) throw new RuntimeException(s"Cycle in templates involving $file: ${ctx.resolving}") + val layoutTemplate = layout.map(name => ctx.layouts.getOrElse(name, throw new RuntimeException(s"No layouts named $name in ${ctx.layouts}")) ) @@ -110,11 +121,52 @@ case class TemplateFile( case other => other // Library requires mutable maps.. - val mutableProperties = new JHashMap(ctx.properties.transform((_, v) => asJavaElement(v)).asJava) + val mutableProperties = new JHashMap[String, Any]( + ctx.properties.transform((_, v) => asJavaElement(v)).asJava + ) + // Initialize liqpParser with a default value + var liqpParser: TemplateParser = TemplateParser.Builder().withFlavor(Flavor.JEKYLL).build() + + // Activate additional features based on the experimental features flag + if (ssctx.args.experimentalFeatures) { + // report.warning("This project has experimental features turned on!!") + + // Load the data from the YAML file in the _data folder + val dataPath = ssctx.root.toPath.resolve("_data") + val dataMap = DataLoader().loadDataDirectory(dataPath.toString) + val siteData = new JHashMap[String, Any]() + siteData.put("data", dataMap) + + // Assign the path for Include Tag + val includePath = ssctx.root.toPath.resolve("_includes") + IncludeTag.setDocsFolder(includePath.toString) + + // load the config data + val configLoader = new SiteConfigLoader() + val configMap = configLoader.loadConfig(ssctx.root.toPath.toString) + val siteConfig: java.util.Map[String, Any] = configMap.convertToJava + + // Set the configuration value for LanguagePickerTag + LanguagePickerTag.setConfigValue(configMap) + siteData.put("config", siteConfig) + + mutableProperties.put("site", siteData) + + // Rebuild liqpParser with all the tags and blocks + liqpParser = TemplateParser.Builder() + .withFlavor(Flavor.JEKYLL) + .withBlock(AltDetails()) + .withBlock(TabsBlock()) + .withBlock(TabBlock()) + .withTag(IncludeTag()) + .withTag(LanguagePickerTag()) + .build() + } + + // Parse and render the template + val rendered = liqpParser.parse(this.rawCode).render(mutableProperties) - val parseSettings = ParseSettings.Builder().withFlavor(Flavor.JEKYLL).build() - val rendered = Template.parse(this.rawCode, parseSettings).render(mutableProperties) // We want to render markdown only if next template is html val code = if (isHtml || layoutTemplate.exists(!_.isHtml)) rendered else