Skip to content

Commit

Permalink
PDF/single page support (#370)
Browse files Browse the repository at this point in the history
* PDF/single page support
  • Loading branch information
jroper authored Feb 21, 2020
1 parent 0efc66c commit f4054b8
Show file tree
Hide file tree
Showing 40 changed files with 1,237 additions and 22 deletions.
2 changes: 1 addition & 1 deletion appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ install:
build_script:
- sbt clean compile
test_script:
- sbt verify
- sbt verify-no-docker
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,4 @@ lazy val docs = (project in file("docs"))
)

addCommandAlias("verify", ";test:compile ;compile:doc ;test ;scripted ;docs/paradox")
addCommandAlias("verify-no-docker", ";test:compile ;compile:doc ;test ;scripted paradox/* ;docs/paradox")
75 changes: 74 additions & 1 deletion core/src/main/scala/com/lightbend/paradox/ParadoxProcessor.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import scala.util.matching.Regex
/**
* Markdown site processor.
*/
class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer) {
class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer, singlePageWriter: Writer = SinglePageSupport.writer) {

/**
* Process all mappings to build the site.
Expand Down Expand Up @@ -216,6 +216,78 @@ class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer)
}
}

def processSinglePage(
mappings: Seq[(File, String)],
outputDirectory: File,
sourceSuffix: String,
targetSuffix: String,
illegalLinkPath: Regex,
groups: Map[String, Seq[String]],
properties: Map[String, String],
navDepth: Int,
navExpandDepth: Option[Int],
expectedRoots: List[String],
pageTemplate: PageTemplate,
print: Boolean,
logger: ParadoxLogger): Either[String, Seq[(File, String)]] = {

require(!groups.values.flatten.map(_.toLowerCase).groupBy(identity).values.exists(_.size > 1), "Group names may not overlap")

val errorCollector = new ErrorCollector

val roots = parsePages(mappings, Path.replaceSuffix(sourceSuffix, targetSuffix), properties, errorCollector)
val pages = Page.allPages(roots)
val globalPageMappings = rootPageMappings(roots)

val navToc = new SinglePageSupport.SinglePageTableOfContents(maxDepth = navDepth, maxExpandDepth = navExpandDepth)

@tailrec
def render(location: Option[Location[Page]], rendered: Seq[PageContents] = Seq.empty): Seq[PageContents] = location match {
case Some(loc) =>
val page = loc.tree.label
checkDuplicateAnchors(page, logger)
val pageProperties = properties ++ page.properties.get
val currentMapping = Path.generateTargetFile(Path.relativeLocalPath(page.rootSrcPage, page.file.getPath), globalPageMappings)
val writerContext = Writer.Context(loc, pages, reader, singlePageWriter,
new PagedErrorContext(errorCollector, page), logger, currentMapping, sourceSuffix, targetSuffix, illegalLinkPath,
groups, pageProperties)
val pageContents = PageContents(Nil, groups, loc, singlePageWriter, writerContext, navToc, new TableOfContents())
render(loc.next, rendered :+ pageContents)
case None => rendered
}

if (expectedRoots.sorted != roots.map(_.label.path).sorted)
errorCollector(
s"Unexpected top-level pages (pages that do no have a parent in the Table of Contents).\n" +
s"If this is intentional, update the `paradoxRoots` sbt setting to reflect the new expected roots.\n" +
"Current ToC roots: " + roots.map(_.label.path).sorted.mkString("[", ", ", "]" + "\n") +
"Specified ToC roots: " + expectedRoots.sorted.mkString("[", ", ", "]" + "\n"
))

outputDirectory.mkdirs()
val results = roots.flatMap { root =>
val pages = render(Some(root.location))
val page = root.location.tree.label
val outputFile = new File(outputDirectory, page.path)
outputFile.getParentFile.mkdirs
val pagesToRender = pages.tail
val pageName = if (print) pageTemplate.defaultPrintName else pageTemplate.defaultSingleName
val cover = if (print) {
val printCover = new File(outputDirectory, "print-cover.html")
Some(pageTemplate.writePrintCover("print-cover", pages.head, printCover) -> "print-cover.html")
} else None

val single = pageTemplate.writeSingle(page.properties(Page.Properties.DefaultSingleLayoutMdIndicator, pageName), pages.head, pagesToRender, outputFile) -> page.path

cover.toSeq :+ single
}

if (errorCollector.hasErrors) {
errorCollector.logErrors(logger)
Left(s"Paradox failed with ${errorCollector.errorCount} errors")
} else Right(results)
}

private def checkDuplicateAnchors(page: Page, logger: ParadoxLogger): Unit = {
val anchors = (page.headers.flatMap(_.toSet) :+ page.h1).map(_.path) ++ page.anchors.map(_.path)
anchors
Expand Down Expand Up @@ -269,6 +341,7 @@ class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer)
lazy val hasSubheaders = page.headers.nonEmpty
lazy val getToc = writer.writeToc(pageToc.headers(loc), context)
lazy val getSource_url = githubLink(Some(loc)).getHtml
def getPath = page.path

// So you can $page.properties.("project.name")$
lazy val getProperties = context.properties.asJava
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ object Page {
object Properties {
val DefaultOutMdIndicator = "out"
val DefaultLayoutMdIndicator = "layout"
val DefaultSingleLayoutMdIndicator = "single-layout"
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*
* Copyright © 2015 - 2019 Lightbend, Inc. <http://www.lightbend.com>
*
* 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 java.net.URI

import com.lightbend.paradox.markdown.Writer.Context
import com.lightbend.paradox.tree.Tree
import org.pegdown.{ LinkRenderer, Printer, ToHtmlSerializer }
import org.pegdown.ast.{ AnchorLinkNode, AnchorLinkSuperNode, AutoLinkNode, DirectiveNode, ExpImageNode, ExpLinkNode, HeaderNode, MailLinkNode, Node, RefImageNode, RefLinkNode, TextNode, Visitor, WikiLinkNode }
import org.pegdown.plugins.ToHtmlSerializerPlugin

import scala.collection.JavaConverters._

object SinglePageSupport {

def writer: Writer = new Writer(new SinglePageToHtmlSerializer(_))

def defaultPlugins(directives: Seq[Context => Directive]): Seq[Context => ToHtmlSerializerPlugin] =
Writer.defaultPlugins(directives).map { plugin =>
{ context: Context =>
plugin(context) match {
case _: AnchorLinkSerializer => new SinglePageAnchorLinkSerializer(context)
case other => other
}
}
}

def defaultDirectives: Seq[Context => Directive] = Writer.defaultDirectives.map { directive =>
{ context: Context =>
directive(context) match {
case ref: RefDirective => new SinglePageRefDirective(ref)
case toc: TocDirective => new SinglePageTocDirective(toc)
case other => other
}
}
}

def defaultLinks: Context => LinkRenderer = c => new SinglePageLinkRenderer(c, Writer.defaultLinks(c))

class SinglePageRefDirective(refDirective: RefDirective) extends InlineDirective("ref", "ref:") with SourceDirective {

override def ctx: Context = refDirective.ctx

def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = {
val source = resolvedSource(node, page)
ctx.pageMappings(source).flatMap(path => check(node, path)) match {
case Some(path) =>
val resolved = URI.create(ctx.page.path).resolve(path).getPath
val link = if (path.contains("#")) {
val anchor = path.substring(path.lastIndexOf('#') + 1)
s"#$resolved~$anchor"
} else {
s"#$resolved"
}
new ExpLinkNode("", link, node.contentsNode).accept(visitor)
case None =>
ctx.error(s"Unknown page [$source]", node)
}
}

private def check(node: DirectiveNode, path: String): Option[String] = {
ctx.paths.get(Path.resolve(page.path, path)).map { target =>
if (path.contains("#")) {
val anchor = path.substring(path.lastIndexOf('#'))
val headers = (target.headers.flatMap(_.toSet) :+ target.h1).map(_.path) ++ target.anchors.map(_.path)
if (!headers.contains(anchor)) {
ctx.error(s"Unknown anchor [$path]", node)
}
}
path
}
}
}

class SinglePageTocDirective(toc: TocDirective) extends Directive {
override def names: Seq[String] = toc.names

override def format: Set[DirectiveNode.Format] = toc.format

override def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = {
// Render nothing.
}
}

class SinglePageLinkRenderer(ctx: Writer.Context, delegate: LinkRenderer) extends LinkRenderer {

override def render(node: AutoLinkNode): LinkRenderer.Rendering = delegate.render(node)

override def render(node: ExpImageNode, text: String): LinkRenderer.Rendering = {
val uri = URI.create(node.url)
val relativeToBase = if (uri.getAuthority == null) {
val path = URI.create(ctx.page.path).resolve(uri).getPath
new ExpImageNode(node.title, path, node.getChildren.get(0))
} else node
delegate.render(relativeToBase, text)
}

override def render(node: MailLinkNode): LinkRenderer.Rendering = delegate.render(node)

override def render(node: RefLinkNode, url: String, title: String, text: String): LinkRenderer.Rendering =
delegate.render(node, url, title, text)

override def render(node: RefImageNode, url: String, title: String, alt: String): LinkRenderer.Rendering = {
val uri = URI.create(url)
println("Rendering image: " + uri)
val relativeToBase = if (uri.getAuthority == null) {
println("is relative")
URI.create(ctx.page.path).resolve(uri).getPath
} else url
println("path: " + relativeToBase)
delegate.render(node, relativeToBase, title, alt)
}

override def render(node: WikiLinkNode): LinkRenderer.Rendering = delegate.render(node)

override def render(node: AnchorLinkNode): LinkRenderer.Rendering = {
val name = s"${ctx.page.path}~${node.getName}"
new LinkRenderer.Rendering(s"#$name", node.getText).withAttribute("name", name)
}

override def render(node: ExpLinkNode, text: String): LinkRenderer.Rendering = delegate.render(node, text)
}

class SinglePageAnchorLinkSerializer(ctx: Writer.Context) extends ToHtmlSerializerPlugin {
def visit(node: Node, visitor: Visitor, printer: Printer): Boolean = node match {
case anchor: AnchorLinkSuperNode =>
val name = s"${ctx.page.path}~${anchor.name}"
printer.print(s"""<a href="#$name" name="$name" class="anchor"><span class="anchor-link"></span></a><span class="header-title">""")
anchor.getChildren.asScala.foreach(_.accept(visitor))
printer.print("</span>")
true
case _ => false
}
}

class SinglePageToHtmlSerializer(ctx: Writer.Context) extends ToHtmlSerializer(
defaultLinks(ctx),
Writer.defaultVerbatims.asJava,
defaultPlugins(defaultDirectives).map(p => p(ctx)).asJava
) {

override def visit(node: HeaderNode): Unit = {
val offsetDepth = node.getLevel + ctx.location.depth

def visitHeaderChildren(node: HeaderNode): Unit = {
node.getChildren.asScala.toList match {
case List(anchorLink: AnchorLinkNode, text: TextNode) =>
linkRenderer.render(anchorLink)
printer.print("""<span class="header-title">""")
text.accept(this)
printer.print("</span>")
case List(anchorLink: AnchorLinkSuperNode) =>
anchorLink.accept(this)
case other =>
ctx.logger.warn("Rendering header that isn't an anchor link followed by text, or anchor link supernode, it will not have its content wrapped in a header-title span, and so won't be numbered: " + other)
visitChildren(node)
}
}
if (offsetDepth > 6) {
printer.println().print("<div class=\"h").print(offsetDepth.toString).print("\">")
visitHeaderChildren(node)
printer.print("</div>").println()
} else {
printer.println().print("<h").print(offsetDepth.toString).print('>')
visitHeaderChildren(node)
printer.print("</h").print(offsetDepth.toString).print('>').println()
}
}

}

class SinglePageTableOfContents(maxDepth: Int = 6, maxExpandDepth: Option[Int] = None) extends TableOfContents(true, true, false, maxDepth, maxExpandDepth) {
override protected def link[A <: Linkable](base: String, linkable: A, active: Option[Tree.Location[Page]]): Node = {
val path = linkable match {
case page: Page => page.path
case header: Header => header.path.replace('#', '~')
}

new ExpLinkNode("", "#" + base + path, linkable.label)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ class TableOfContents(pages: Boolean = true, headers: Boolean = true, ordered: B
}
}

private def link[A <: Linkable](base: String, linkable: A, active: Option[Location[Page]]): Node = {
protected def link[A <: Linkable](base: String, linkable: A, active: Option[Location[Page]]): Node = {
val (path, classAttributes) = linkable match {
case page: Page =>
val isActive = active.exists(_.tree.label.path == page.path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,50 @@ import collection.concurrent.TrieMap
/**
* Page template writer.
*/
class PageTemplate(directory: File, val defaultName: String = "page", startDelimiter: Char = '$', stopDelimiter: Char = '$') {
class PageTemplate(directory: File, val defaultName: String = "page", val defaultSingleName: String = "single", val defaultPrintName: String = "print", startDelimiter: Char = '$', stopDelimiter: Char = '$') {
private val templates = new STRawGroupDir(directory.getAbsolutePath, startDelimiter, stopDelimiter)

/**
* Write a templated page to the target file.
*/
def write(name: String, contents: PageTemplate.Contents, target: File): File = {
import scala.collection.JavaConverters._
write(name, target) { t =>
// TODO, only load page properties, not global ones
for (content <- contents.getProperties.asScala.filterNot(_._1.contains("."))) { t.add(content._1, content._2) }
t.add("page", contents)
}
}

/**
* Write all the templated pages to the target file.
*/
def writeSingle(name: String, firstPage: PageTemplate.Contents, contents: Seq[PageTemplate.Contents], target: File): File = {
import scala.collection.JavaConverters._
write(name, target) { t =>
t.add("page", firstPage)
t.add("pages", contents.asJava)
}
}

def writePrintCover(name: String, page: PageTemplate.Contents, target: File): File = {
write(name, target) { t =>
t.add("page", page)
}
}

private def write(name: String, target: File)(addVars: ST => ST): File = {
val template = Option(templates.getInstanceOf(name)) match {
case Some(t) => // TODO, only load page properties, not global ones
for (content <- contents.getProperties.asScala.filterNot(_._1.contains("."))) { t.add(content._1, content._2) }
t.add("page", contents)
case Some(t) =>
addVars(t)
case None => sys.error(s"StringTemplate '$name' was not found for '$target'. Create a template or set a theme that contains one.")
}
val osWriter = new OutputStreamWriter(new FileOutputStream(target), StandardCharsets.UTF_8)
val noIndentWriter = new NoIndentWriter(osWriter)
template.write(noIndentWriter)
osWriter.close
osWriter.close()
target
}

}

object PageTemplate {
Expand All @@ -69,6 +91,7 @@ object PageTemplate {
def getToc: String
def getSource_url: String
def getProperties: JMap[String, String]
def getPath: String
}

/**
Expand Down
1 change: 1 addition & 0 deletions docs/src/main/paradox/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ Paradox is a Markdown documentation tool for software projects.
* [Groups](groups.md)
* [Customization](customization/index.md)
* [Validation](validation.md)
* [Single Page HTML/PDF](single.md)

@@@
Loading

0 comments on commit f4054b8

Please sign in to comment.