All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.lightbend.paradox.markdown.Directive.scala Maven / Gradle / Ivy

The newest version!
/*
 * Copyright © 2015 - 2019 Lightbend, Inc. 
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.lightbend.paradox.markdown

import com.lightbend.paradox.tree.Tree.Location
import java.io.{ File, FileNotFoundException }
import java.util.Optional

import org.pegdown.ast._
import org.pegdown.ast.DirectiveNode.Format._
import org.pegdown.plugins.ToHtmlSerializerPlugin
import org.pegdown.{ Printer, ToHtmlSerializer }

import scala.collection.JavaConverters._

/**
 * Serialize directives, checking the name and format against registered directives.
 */
class DirectiveSerializer(directives: Seq[Directive]) extends ToHtmlSerializerPlugin {
  val directiveMap = directives.flatMap(d => d.names.map(n => (n, d))).toMap

  def visit(node: Node, visitor: Visitor, printer: Printer): Boolean = node match {
    case dnode: DirectiveNode =>
      directiveMap.get(dnode.name) match {
        case Some(directive) if directive.format(dnode.format) =>
          directive.render(dnode, visitor, printer)
        case _ => // printer.print(s"")
      }
      true
    case _ => false
  }
}

// Directive plugins

/**
 * Base directive class, for directive specific serialization.
 */
abstract class Directive {
  def names: Seq[String]

  def format: Set[DirectiveNode.Format]

  def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit
}

/**
 * Inline directive.
 */
abstract class InlineDirective(val names: String*) extends Directive {
  val format = Set(Inline)
}

/**
 * Leaf block directive.
 */
abstract class LeafBlockDirective(val names: String*) extends Directive {
  val format = Set(LeafBlock)
}

/**
 * Container block directive.
 */
abstract class ContainerBlockDirective(val names: String*) extends Directive {
  val format = Set(ContainerBlock)
}

/**
 * Directives with defined "source" semantics.
 */
trait SourceDirective { this: Directive =>
  def page: Page
  def variables: Map[String, String]

  protected def resolvedSource(node: DirectiveNode, page: Page): String = {
    def ref(key: String) =
      referenceMap.get(key.filterNot(_.isWhitespace).toLowerCase).map(_.getUrl).getOrElse(
        throw new RefDirective.LinkException(s"Undefined reference key [$key] in [${page.path}]"))
    Writer.substituteVarsInString(node.source match {
      case x: DirectiveNode.Source.Direct => x.value
      case x: DirectiveNode.Source.Ref    => ref(x.value)
      case DirectiveNode.Source.Empty     => ref(node.label)
    }, variables)
  }

  protected def resolveFile(propPrefix: String, source: String, page: Page, variables: Map[String, String]): File =
    SourceDirective.resolveFile(propPrefix, source, page.file, variables)

  private lazy val referenceMap: Map[String, ReferenceNode] = {
    val tempRoot = new RootNode
    tempRoot.setReferences(page.markdown.getReferences)
    var result = Map.empty[String, ReferenceNode]
    new ToHtmlSerializer(null) {
      toHtml(tempRoot)
      result = references.asScala.toMap
    }
    result
  }
}

object SourceDirective {
  def resolveFile(propPrefix: String, source: String, pageFile: File, variables: Map[String, String]): File =
    source match {
      case s if s startsWith "$" =>
        val baseKey = s.drop(1).takeWhile(_ != '$')
        val base = new File(PropertyUrl(s"$propPrefix.$baseKey.base_dir", variables.get).base.trim)
        val effectiveBase = if (base.isAbsolute) base else new File(pageFile.getParentFile, base.toString)
        new File(effectiveBase, s.drop(baseKey.length + 2))
      case s if s startsWith "/" =>
        val base = new File(PropertyUrl(SnipDirective.buildBaseDir, variables.get).base.trim)
        new File(base, s)
      case s =>
        new File(pageFile.getParentFile, s)
    }
}

// Default directives

/**
 * Ref directive.
 *
 * Refs are for links to internal pages. The file extension is replaced when rendering.
 * Links are validated to ensure they point to a known page.
 */
case class RefDirective(page: Page, pathExists: String => Boolean, convertPath: String => String, variables: Map[String, String])
  extends InlineDirective("ref", "ref:") with SourceDirective {

  def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit =
    new ExpLinkNode("", check(convertPath(resolvedSource(node, page))), node.contentsNode).accept(visitor)

  private def check(path: String): String = {
    if (!pathExists(Path.resolve(page.path, path)))
      throw new RefDirective.LinkException(s"Unknown page [$path] referenced from [${page.path}]")
    path
  }
}

object RefDirective {

  /**
   * Exception thrown for unknown pages in reference links.
   */
  class LinkException(message: String) extends RuntimeException(message)

}

/**
 * Link to external sites using URI templates.
 */
abstract class ExternalLinkDirective(names: String*)
  extends InlineDirective(names: _*) with SourceDirective {

  import ExternalLinkDirective._

  def resolveLink(node: DirectiveNode, location: String): Url

  def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit =
    new ExpLinkNode("", resolvedSource(node, page), node.contentsNode).accept(visitor)

  override protected def resolvedSource(node: DirectiveNode, page: Page): String = {
    val link = super.resolvedSource(node, page)
    try {
      val resolvedLink = resolveLink(node: DirectiveNode, link).base.normalize.toString
      if (resolvedLink startsWith (".../")) page.base + resolvedLink.drop(4) else resolvedLink
    } catch {
      case Url.Error(reason) =>
        throw new LinkException(s"Failed to resolve [$link] referenced from [${page.path}] because $reason")
      case e: FileNotFoundException =>
        throw new LinkException(s"Failed to resolve [$link] referenced from [${page.path}] to a file: ${e.getMessage}")
      case e: Snippet.SnippetException =>
        throw new LinkException(s"Failed to resolve [$link] referenced from [${page.path}]: ${e.getMessage}")
    }
  }
}

object ExternalLinkDirective {

  /**
   * Exception thrown for unknown or invalid links.
   */
  class LinkException(reason: String) extends RuntimeException(reason)

}

/**
 * ExtRef directive.
 *
 * Link to external pages using URL templates.
 */
case class ExtRefDirective(page: Page, variables: Map[String, String])
  extends ExternalLinkDirective("extref", "extref:") {

  def resolveLink(node: DirectiveNode, link: String): Url = {
    link.split(":", 2) match {
      case Array(scheme, expr) => PropertyUrl(s"extref.$scheme.base_url", variables.get).format(expr)
      case _                   => throw Url.Error("URL has no scheme")
    }
  }

}

/**
 * API doc directive.
 *
 * Link to javadoc and scaladoc based on package prefix. Will match the
 * configured base URL with the longest package prefix. For example,
 * given:
 *
 * - `scaladoc.akka.base_url=http://doc.akka.io/api/akka/x.y.z`
 * - `scaladoc.akka.http.base_url=http://doc.akka.io/api/akka-http/x.y.z`
 *
 * Then `@scaladoc[Http](akka.http.scaladsl.Http)` will match the latter.
 */
abstract class ApiDocDirective(name: String, page: Page, variables: Map[String, String])
  extends ExternalLinkDirective(name, name + ":") {

  def resolveApiLink(base: Url, link: String): Url

  val defaultBaseUrl = PropertyUrl(name + ".base_url", variables.get)
  val ApiDocProperty = raw"""$name\.(.*)\.base_url""".r
  val baseUrls = variables.collect {
    case (property @ ApiDocProperty(pkg), url) => (pkg, PropertyUrl(property, variables.get))
  }

  def resolveLink(node: DirectiveNode, link: String): Url = {
    val levels = link.split("[.]")
    val packages = (1 to levels.init.size).map(levels.take(_).mkString("."))
    val baseUrl = packages.reverse.collectFirst(baseUrls).getOrElse(defaultBaseUrl).resolve()
    val resolvedLink = resolveApiLink(baseUrl, link)
    val resolvedPath = resolvedLink.base.getPath
    if (resolvedPath startsWith ".../") resolvedLink.copy(path = page.base + resolvedPath.drop(4)) else resolvedLink
  }

}

case class ScaladocDirective(page: Page, variables: Map[String, String])
  extends ApiDocDirective("scaladoc", page, variables) {

  def resolveApiLink(baseUrl: Url, link: String): Url = {
    val url = Url(link).base
    val path = url.getPath.replace('.', '/') + ".html"
    (baseUrl / path) withFragment (url.getFragment)
  }

}

case class JavadocDirective(page: Page, variables: Map[String, String])
  extends ApiDocDirective("javadoc", page, variables) {

  def resolveApiLink(baseUrl: Url, link: String): Url = {
    val url = Url(link).base
    val path = url.getPath.replace('.', '/') + ".html"
    baseUrl.withEndingSlash.withQuery(path).withFragment(url.getFragment)
  }

}

object GitHubResolver {
  val baseUrl = "github.base_url"
}

trait GitHubResolver {

  def variables: Map[String, String]

  val IssuesLink = """([^/]+/[^/]+)?#([0-9]+)""".r
  val CommitLink = """(([^/]+/[^/]+)?@)?(\p{XDigit}{5,40})""".r
  val TreeUrl = """(.*github.com/[^/]+/[^/]+/tree/[^/]+)""".r
  val ProjectUrl = """(.*github.com/[^/]+/[^/]+).*""".r

  val baseUrl = PropertyUrl(GitHubResolver.baseUrl, variables.get)

  protected def resolvePath(page: Page, source: String, labelOpt: Option[String]): Url = {
    val pathUrl = Url.parse(source, "path is invalid")
    val path = pathUrl.base.getPath
    val root = variables.get("github.root.base_dir") match {
      case None      => throw Url.Error("[github.root.base_dir] is not defined")
      case Some(dir) => new File(dir)
    }
    val file = path match {
      case p if p.startsWith(Path.toUnixStyleRootPath(root.getAbsolutePath)) => new File(p)
      case p if p.startsWith("/")                                            => new File(root, path.drop(1))
      case p                                                                 => new File(page.file.getParentFile, path)
    }
    val labelFragment =
      for {
        label <- labelOpt
        (min, max) <- Snippet.extractLabelRange(file, label)
      } yield {
        if (min == max)
          s"L$min"
        else
          s"L$min-L$max"
      }
    val fragment = labelFragment.getOrElse(pathUrl.base.getFragment)
    val treePath = Path.relativeLocalPath(root.getAbsolutePath, file.getAbsolutePath)

    (treeUrl / treePath) withFragment fragment
  }

  protected def resolveProject(project: String) = {
    Option(project) match {
      case Some(path) => Url("https://github.com") / path
      case None       => projectUrl
    }
  }

  protected def projectUrl = baseUrl.collect {
    case ProjectUrl(url) => url
    case _               => throw Url.Error(s"[${GitHubResolver.baseUrl}] is not a project URL")
  }

  protected def treeUrl = baseUrl.collect {
    case TreeUrl(url)    => url
    case ProjectUrl(url) => url + "/tree/master"
    case _               => throw Url.Error(s"[${GitHubResolver.baseUrl}] is not a project or versioned tree URL")
  }

}

/**
 * GitHub directive.
 *
 * Link to GitHub project entities like issues, commits and source code.
 * Supports most of the references documented in:
 * https://help.github.com/articles/autolinked-references-and-urls/
 */
case class GitHubDirective(page: Page, variables: Map[String, String])
  extends ExternalLinkDirective("github", "github:") with GitHubResolver {

  def resolveLink(node: DirectiveNode, link: String): Url = {
    link match {
      case IssuesLink(project, issue)     => resolveProject(project) / "issues" / issue
      case CommitLink(_, project, commit) => resolveProject(project) / "commit" / commit
      case path                           => resolvePath(page, path, Option(node.attributes.identifier()))
    }
  }

}

/**
 * Snip directive.
 *
 * Extracts snippets from source files into verbatim blocks.
 */
case class SnipDirective(page: Page, variables: Map[String, String])
  extends LeafBlockDirective("snip") with SourceDirective with GitHubResolver {

  def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = {
    try {
      val labels = node.attributes.values("identifier").asScala
      val source = resolvedSource(node, page)
      val filterLabels = node.attributes.booleanValue("filterLabels", variables.get("snip.filterLabels").forall(_ == "true"))
      val file = resolveFile("snip", source, page, variables)
      val (text, snippetLang) = Snippet(file, labels, filterLabels)
      val lang = Option(node.attributes.value("type")).getOrElse(snippetLang)
      val group = Option(node.attributes.value("group")).getOrElse("")
      val sourceUrl = if (variables.contains(GitHubResolver.baseUrl) && variables.getOrElse(SnipDirective.showGithubLinks, "false") == "true") {
        Optional.of(resolvePath(page, Path.toUnixStyleRootPath(file.getAbsolutePath), labels.headOption).base.normalize.toString)
      } else Optional.empty[String]()
      new VerbatimGroupNode(text, lang, group, node.attributes.classes, sourceUrl).accept(visitor)
    } catch {
      case e: FileNotFoundException =>
        throw new SnipDirective.LinkException(s"Unknown snippet [${e.getMessage}] referenced from [${page.path}]")
    }
  }

}

object SnipDirective {

  val showGithubLinks = "snip.github_link"
  val buildBaseDir = "snip.build.base_dir"

  /**
   * Exception thrown for unknown snip links.
   */
  class LinkException(message: String) extends RuntimeException(message)

}

/**
 * Fiddle directive.
 *
 * Extracts fiddles from source files into fiddle blocks.
 */
case class FiddleDirective(page: Page, variables: Map[String, String])
  extends LeafBlockDirective("fiddle") with SourceDirective {

  def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = {
    try {
      val labels = node.attributes.values("identifier").asScala

      val integrationScriptUrl =
        node.attributes.value("integrationScriptUrl", "https://embed.scalafiddle.io/integration.js")

      // integration params as listed here:
      // https://github.com/scalafiddle/scalafiddle-core/tree/master/integrations#scalafiddle-integration
      // 'selector' is excluded on purpose to not complicate logic and increase maintainability
      val validParams = Seq("prefix", "dependency", "scalaversion", "template", "theme", "minheight", "layout")

      val params = validParams.map(k => Option(node.attributes.value(k)).map(x => s"data-$k=$x").getOrElse("")).mkString(" ")

      val source = resolvedSource(node, page)
      val file = resolveFile("fiddle", source, page, variables)
      val filterLabels = node.attributes.booleanValue("filterLabels", variables.get("fiddle.filterLabels").forall(_ == "true"))
      val (code, _) = Snippet(file, labels, filterLabels)

      printer.println.print(s"""
        
$code
""" ) } catch { case e: FileNotFoundException => throw new FiddleDirective.LinkException(s"Unknown fiddle [${e.getMessage}] referenced from [${page.path}]") } } } object FiddleDirective { /** * Exception thrown for unknown snip links. */ class LinkException(message: String) extends RuntimeException(message) } /** * Table of contents directive. * * Placeholder to insert a serialized table of contents, using the page and header trees. * Depth and whether to include pages or headers can be specified in directive attributes. */ case class TocDirective(location: Location[Page], includeIndexes: List[Int]) extends LeafBlockDirective("toc") { def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { val classes = node.attributes.classesString val depth = node.attributes.intValue("depth", 6) val pages = node.attributes.booleanValue("pages", true) val headers = node.attributes.booleanValue("headers", true) val ordered = node.attributes.booleanValue("ordered", false) val toc = new TableOfContents(pages, headers, ordered, depth) printer.println.print(s"""
""") toc.markdown(location, node.getStartIndex, includeIndexes).accept(visitor) printer.println.print("
") } } /** * Var directive. * * Looks up property values and renders escaped text. */ case class VarDirective(variables: Map[String, String]) extends InlineDirective("var", "var:") { def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { new SpecialTextNode(variables.get(node.label).getOrElse(s"<${node.label}>")).accept(visitor) } } /** * Vars directive. * * Replaces property values in verbatim blocks. */ case class VarsDirective(variables: Map[String, String]) extends ContainerBlockDirective("vars") { def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { import scala.collection.JavaConverters._ node.contentsNode.getChildren.asScala.headOption match { case Some(verbatim: VerbatimNode) => val startDelimiter = node.attributes.value("start-delimiter", "$") val stopDelimiter = node.attributes.value("stop-delimiter", "$") val text = variables.foldLeft(verbatim.getText) { case (str, (key, value)) => str.replace(startDelimiter + key + stopDelimiter, value) } new VerbatimNode(text, verbatim.getType).accept(visitor) case _ => node.contentsNode.accept(visitor) } } } /** * Callout directive. * * Renders call-out divs. */ case class CalloutDirective(name: String, defaultTitle: String) extends ContainerBlockDirective(Array(name): _*) { def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { val classes = node.attributes.classesString val title = node.attributes.value("title", defaultTitle) printer.print(s"""
""") printer.print(s"""
$title
""") node.contentsNode.accept(visitor) printer.print("""
""") } } /** * Wrap directive. * * Wraps inner content in a `div` or `p`, optionally with custom `id` and/or `class` attributes. */ case class WrapDirective(typ: String) extends ContainerBlockDirective(Array(typ, typ.toUpperCase): _*) { def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { val id = node.attributes.identifier match { case null => "" case x => s""" id="$x"""" } val classes = node.attributes.classesString match { case "" => "" case x => s""" class="$x"""" } printer.print(s"""<$typ$id$classes>""") node.contentsNode.accept(visitor) printer.print(s"") } } /** * Inline wrap directive * * Wraps inner contents in a `span`, optionally with custom `id` and/or `class` attributes. */ case class InlineWrapDirective(typ: String) extends InlineDirective(Array(typ, typ.toUpperCase): _*) { def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { val id = node.attributes.identifier match { case null => "" case x => s""" id="$x"""" } val classes = node.attributes.classesString match { case "" => "" case x => s""" class="$x"""" } printer.print(s"""<$typ$id$classes>""") node.contentsNode.accept(visitor) printer.print(s"") } } case class InlineGroupDirective(groups: Seq[String]) extends InlineDirective(groups: _*) { def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { printer.print(s"""""") node.contentsNode.accept(visitor) printer.print(s"") } } /** * Dependency directive. */ case class DependencyDirective(variables: Map[String, String]) extends LeafBlockDirective("dependency") { val ScalaVersion = variables.get("scala.version") val ScalaBinaryVersion = variables.get("scala.binary.version") def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { node.contentsNode.getChildren.asScala.headOption match { case Some(text: TextNode) => renderDependency(text.getText, node, printer) case _ => node.contentsNode.accept(visitor) } } def renderDependency(tools: String, node: DirectiveNode, printer: Printer): Unit = { val classes = Seq("dependency", node.attributes.classesString).filter(_.nonEmpty) val dependencyPostfixes = node.attributes.keys().asScala.toSeq .filter(_.startsWith("group")).sorted.map(_.replace("group", "")) val startDelimiter = node.attributes.value("start-delimiter", "$") val stopDelimiter = node.attributes.value("stop-delimiter", "$") def coordinate(name: String): Option[String] = Option(node.attributes.value(name)).map { value => variables.foldLeft(value) { case (str, (key, value)) => str.replace(startDelimiter + key + stopDelimiter, value) } } def requiredCoordinate(name: String): String = coordinate(name).getOrElse(throw DependencyDirective.UndefinedVariable(name)) def sbt(group: String, artifact: String, version: String, scope: Option[String], classifier: Option[String]): String = { val scopeString = scope.map { case s @ ("runtime" | "compile" | "test") => " % " + s.capitalize case s => s""" % "$s"""" } val classifierString = classifier.map(" classifier " + '"' + _ + '"') val extra = (scopeString ++ classifierString).mkString (ScalaVersion, ScalaBinaryVersion) match { case (Some(scalaVersion), _) if artifact.endsWith("_" + scalaVersion) => val strippedArtifact = artifact.substring(0, artifact.length - 1 - scalaVersion.length) s""""$group" % "$strippedArtifact" % "$version"$extra cross CrossVersion.full""" case (_, Some(scalaBinVersion)) if artifact.endsWith("_" + scalaBinVersion) => val strippedArtifact = artifact.substring(0, artifact.length - 1 - scalaBinVersion.length) s""""$group" %% "$strippedArtifact" % "$version"$extra""" case _ => s""""$group" % "$artifact" % "$version"$extra""" } } def gradle(group: String, artifact: String, version: String, scope: Option[String], classifier: Option[String]): String = { val conf = scope.getOrElse("compile") val extra = classifier.map(c => s", classifier: '$c'").getOrElse("") s"""$conf group: '$group', name: '$artifact', version: '$version'$extra""".stripMargin } def mvn(group: String, artifact: String, version: String, scope: Option[String], classifier: Option[String]): String = { val elements = Seq("groupId" -> group, "artifactId" -> artifact, "version" -> version) ++ classifier.map("classifier" -> _) ++ scope.map("scope" -> _) elements.map { case (element, value) => s" <$element>$value" }.mkString("\n", "\n", "\n").replace("<", "<").replace(">", ">") } printer.print(s"""
""") tools.split("[,]").map(_.trim).filter(_.nonEmpty).foreach { tool => val (lang, code) = tool match { case "sbt" => val artifacts = dependencyPostfixes.map { dp => sbt( requiredCoordinate(s"group$dp"), requiredCoordinate(s"artifact$dp"), requiredCoordinate(s"version$dp"), coordinate(s"scope$dp"), coordinate(s"classifier$dp") ) } val libraryDependencies = artifacts match { case Seq(artifact) => s"libraryDependencies += $artifact" case artifacts => Seq("libraryDependencies ++= Seq(", artifacts.map(a => s" $a").mkString(",\n"), ")").mkString("\n") } ("scala", libraryDependencies) case "gradle" | "Gradle" => val artifacts = dependencyPostfixes.map { dp => gradle( requiredCoordinate(s"group$dp"), requiredCoordinate(s"artifact$dp"), requiredCoordinate(s"version$dp"), coordinate(s"scope$dp"), coordinate(s"classifier$dp") ) } val libraryDependencies = Seq("dependencies {", artifacts.map(a => s" $a").mkString(",\n"), "}").mkString("\n") ("gradle", libraryDependencies) case "maven" | "Maven" | "mvn" => val artifacts = dependencyPostfixes.map { dp => mvn( requiredCoordinate(s"group$dp"), requiredCoordinate(s"artifact$dp"), requiredCoordinate(s"version$dp"), coordinate(s"scope$dp"), coordinate(s"classifier$dp") ) } ("xml", artifacts.mkString("\n")) } printer.print(s"""
$tool
""") printer.print(s"""
""") printer.print(s"""
$code
""") printer.print(s"""
""") } printer.print("""
""") } } object DependencyDirective { case class UndefinedVariable(name: String) extends RuntimeException(s"'$name' is not defined") } case class IncludeDirective(page: Page, variables: Map[String, String]) extends LeafBlockDirective("include") with SourceDirective { override def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = { throw new IllegalStateException("Include directive should have been handled in markdown preprocessing before render, but wasn't.") } } object IncludeDirective { case class IncludeSourceException(source: DirectiveNode.Source) extends RuntimeException(s"Only explicit links are supported by the include directive, reference links are not: " + source) case class IncludeFormatException(format: String) extends RuntimeException(s"Don't know how to include '*.$format' content.") }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy