Skip to content

Commit f4054b8

Browse files
authored
PDF/single page support (#370)
* PDF/single page support
1 parent 0efc66c commit f4054b8

File tree

40 files changed

+1237
-22
lines changed

40 files changed

+1237
-22
lines changed

appveyor.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ install:
1616
build_script:
1717
- sbt clean compile
1818
test_script:
19-
- sbt verify
19+
- sbt verify-no-docker

build.sbt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,4 @@ lazy val docs = (project in file("docs"))
139139
)
140140

141141
addCommandAlias("verify", ";test:compile ;compile:doc ;test ;scripted ;docs/paradox")
142+
addCommandAlias("verify-no-docker", ";test:compile ;compile:doc ;test ;scripted paradox/* ;docs/paradox")

core/src/main/scala/com/lightbend/paradox/ParadoxProcessor.scala

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import scala.util.matching.Regex
3434
/**
3535
* Markdown site processor.
3636
*/
37-
class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer) {
37+
class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer, singlePageWriter: Writer = SinglePageSupport.writer) {
3838

3939
/**
4040
* Process all mappings to build the site.
@@ -216,6 +216,78 @@ class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer)
216216
}
217217
}
218218

219+
def processSinglePage(
220+
mappings: Seq[(File, String)],
221+
outputDirectory: File,
222+
sourceSuffix: String,
223+
targetSuffix: String,
224+
illegalLinkPath: Regex,
225+
groups: Map[String, Seq[String]],
226+
properties: Map[String, String],
227+
navDepth: Int,
228+
navExpandDepth: Option[Int],
229+
expectedRoots: List[String],
230+
pageTemplate: PageTemplate,
231+
print: Boolean,
232+
logger: ParadoxLogger): Either[String, Seq[(File, String)]] = {
233+
234+
require(!groups.values.flatten.map(_.toLowerCase).groupBy(identity).values.exists(_.size > 1), "Group names may not overlap")
235+
236+
val errorCollector = new ErrorCollector
237+
238+
val roots = parsePages(mappings, Path.replaceSuffix(sourceSuffix, targetSuffix), properties, errorCollector)
239+
val pages = Page.allPages(roots)
240+
val globalPageMappings = rootPageMappings(roots)
241+
242+
val navToc = new SinglePageSupport.SinglePageTableOfContents(maxDepth = navDepth, maxExpandDepth = navExpandDepth)
243+
244+
@tailrec
245+
def render(location: Option[Location[Page]], rendered: Seq[PageContents] = Seq.empty): Seq[PageContents] = location match {
246+
case Some(loc) =>
247+
val page = loc.tree.label
248+
checkDuplicateAnchors(page, logger)
249+
val pageProperties = properties ++ page.properties.get
250+
val currentMapping = Path.generateTargetFile(Path.relativeLocalPath(page.rootSrcPage, page.file.getPath), globalPageMappings)
251+
val writerContext = Writer.Context(loc, pages, reader, singlePageWriter,
252+
new PagedErrorContext(errorCollector, page), logger, currentMapping, sourceSuffix, targetSuffix, illegalLinkPath,
253+
groups, pageProperties)
254+
val pageContents = PageContents(Nil, groups, loc, singlePageWriter, writerContext, navToc, new TableOfContents())
255+
render(loc.next, rendered :+ pageContents)
256+
case None => rendered
257+
}
258+
259+
if (expectedRoots.sorted != roots.map(_.label.path).sorted)
260+
errorCollector(
261+
s"Unexpected top-level pages (pages that do no have a parent in the Table of Contents).\n" +
262+
s"If this is intentional, update the `paradoxRoots` sbt setting to reflect the new expected roots.\n" +
263+
"Current ToC roots: " + roots.map(_.label.path).sorted.mkString("[", ", ", "]" + "\n") +
264+
"Specified ToC roots: " + expectedRoots.sorted.mkString("[", ", ", "]" + "\n"
265+
))
266+
267+
outputDirectory.mkdirs()
268+
val results = roots.flatMap { root =>
269+
val pages = render(Some(root.location))
270+
val page = root.location.tree.label
271+
val outputFile = new File(outputDirectory, page.path)
272+
outputFile.getParentFile.mkdirs
273+
val pagesToRender = pages.tail
274+
val pageName = if (print) pageTemplate.defaultPrintName else pageTemplate.defaultSingleName
275+
val cover = if (print) {
276+
val printCover = new File(outputDirectory, "print-cover.html")
277+
Some(pageTemplate.writePrintCover("print-cover", pages.head, printCover) -> "print-cover.html")
278+
} else None
279+
280+
val single = pageTemplate.writeSingle(page.properties(Page.Properties.DefaultSingleLayoutMdIndicator, pageName), pages.head, pagesToRender, outputFile) -> page.path
281+
282+
cover.toSeq :+ single
283+
}
284+
285+
if (errorCollector.hasErrors) {
286+
errorCollector.logErrors(logger)
287+
Left(s"Paradox failed with ${errorCollector.errorCount} errors")
288+
} else Right(results)
289+
}
290+
219291
private def checkDuplicateAnchors(page: Page, logger: ParadoxLogger): Unit = {
220292
val anchors = (page.headers.flatMap(_.toSet) :+ page.h1).map(_.path) ++ page.anchors.map(_.path)
221293
anchors
@@ -269,6 +341,7 @@ class ParadoxProcessor(reader: Reader = new Reader, writer: Writer = new Writer)
269341
lazy val hasSubheaders = page.headers.nonEmpty
270342
lazy val getToc = writer.writeToc(pageToc.headers(loc), context)
271343
lazy val getSource_url = githubLink(Some(loc)).getHtml
344+
def getPath = page.path
272345

273346
// So you can $page.properties.("project.name")$
274347
lazy val getProperties = context.properties.asJava

core/src/main/scala/com/lightbend/paradox/markdown/Page.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ object Page {
145145
object Properties {
146146
val DefaultOutMdIndicator = "out"
147147
val DefaultLayoutMdIndicator = "layout"
148+
val DefaultSingleLayoutMdIndicator = "single-layout"
148149
}
149150

150151
/**
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Copyright © 2015 - 2019 Lightbend, Inc. <http://www.lightbend.com>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.lightbend.paradox.markdown
18+
19+
import java.net.URI
20+
21+
import com.lightbend.paradox.markdown.Writer.Context
22+
import com.lightbend.paradox.tree.Tree
23+
import org.pegdown.{ LinkRenderer, Printer, ToHtmlSerializer }
24+
import org.pegdown.ast.{ AnchorLinkNode, AnchorLinkSuperNode, AutoLinkNode, DirectiveNode, ExpImageNode, ExpLinkNode, HeaderNode, MailLinkNode, Node, RefImageNode, RefLinkNode, TextNode, Visitor, WikiLinkNode }
25+
import org.pegdown.plugins.ToHtmlSerializerPlugin
26+
27+
import scala.collection.JavaConverters._
28+
29+
object SinglePageSupport {
30+
31+
def writer: Writer = new Writer(new SinglePageToHtmlSerializer(_))
32+
33+
def defaultPlugins(directives: Seq[Context => Directive]): Seq[Context => ToHtmlSerializerPlugin] =
34+
Writer.defaultPlugins(directives).map { plugin =>
35+
{ context: Context =>
36+
plugin(context) match {
37+
case _: AnchorLinkSerializer => new SinglePageAnchorLinkSerializer(context)
38+
case other => other
39+
}
40+
}
41+
}
42+
43+
def defaultDirectives: Seq[Context => Directive] = Writer.defaultDirectives.map { directive =>
44+
{ context: Context =>
45+
directive(context) match {
46+
case ref: RefDirective => new SinglePageRefDirective(ref)
47+
case toc: TocDirective => new SinglePageTocDirective(toc)
48+
case other => other
49+
}
50+
}
51+
}
52+
53+
def defaultLinks: Context => LinkRenderer = c => new SinglePageLinkRenderer(c, Writer.defaultLinks(c))
54+
55+
class SinglePageRefDirective(refDirective: RefDirective) extends InlineDirective("ref", "ref:") with SourceDirective {
56+
57+
override def ctx: Context = refDirective.ctx
58+
59+
def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = {
60+
val source = resolvedSource(node, page)
61+
ctx.pageMappings(source).flatMap(path => check(node, path)) match {
62+
case Some(path) =>
63+
val resolved = URI.create(ctx.page.path).resolve(path).getPath
64+
val link = if (path.contains("#")) {
65+
val anchor = path.substring(path.lastIndexOf('#') + 1)
66+
s"#$resolved~$anchor"
67+
} else {
68+
s"#$resolved"
69+
}
70+
new ExpLinkNode("", link, node.contentsNode).accept(visitor)
71+
case None =>
72+
ctx.error(s"Unknown page [$source]", node)
73+
}
74+
}
75+
76+
private def check(node: DirectiveNode, path: String): Option[String] = {
77+
ctx.paths.get(Path.resolve(page.path, path)).map { target =>
78+
if (path.contains("#")) {
79+
val anchor = path.substring(path.lastIndexOf('#'))
80+
val headers = (target.headers.flatMap(_.toSet) :+ target.h1).map(_.path) ++ target.anchors.map(_.path)
81+
if (!headers.contains(anchor)) {
82+
ctx.error(s"Unknown anchor [$path]", node)
83+
}
84+
}
85+
path
86+
}
87+
}
88+
}
89+
90+
class SinglePageTocDirective(toc: TocDirective) extends Directive {
91+
override def names: Seq[String] = toc.names
92+
93+
override def format: Set[DirectiveNode.Format] = toc.format
94+
95+
override def render(node: DirectiveNode, visitor: Visitor, printer: Printer): Unit = {
96+
// Render nothing.
97+
}
98+
}
99+
100+
class SinglePageLinkRenderer(ctx: Writer.Context, delegate: LinkRenderer) extends LinkRenderer {
101+
102+
override def render(node: AutoLinkNode): LinkRenderer.Rendering = delegate.render(node)
103+
104+
override def render(node: ExpImageNode, text: String): LinkRenderer.Rendering = {
105+
val uri = URI.create(node.url)
106+
val relativeToBase = if (uri.getAuthority == null) {
107+
val path = URI.create(ctx.page.path).resolve(uri).getPath
108+
new ExpImageNode(node.title, path, node.getChildren.get(0))
109+
} else node
110+
delegate.render(relativeToBase, text)
111+
}
112+
113+
override def render(node: MailLinkNode): LinkRenderer.Rendering = delegate.render(node)
114+
115+
override def render(node: RefLinkNode, url: String, title: String, text: String): LinkRenderer.Rendering =
116+
delegate.render(node, url, title, text)
117+
118+
override def render(node: RefImageNode, url: String, title: String, alt: String): LinkRenderer.Rendering = {
119+
val uri = URI.create(url)
120+
println("Rendering image: " + uri)
121+
val relativeToBase = if (uri.getAuthority == null) {
122+
println("is relative")
123+
URI.create(ctx.page.path).resolve(uri).getPath
124+
} else url
125+
println("path: " + relativeToBase)
126+
delegate.render(node, relativeToBase, title, alt)
127+
}
128+
129+
override def render(node: WikiLinkNode): LinkRenderer.Rendering = delegate.render(node)
130+
131+
override def render(node: AnchorLinkNode): LinkRenderer.Rendering = {
132+
val name = s"${ctx.page.path}~${node.getName}"
133+
new LinkRenderer.Rendering(s"#$name", node.getText).withAttribute("name", name)
134+
}
135+
136+
override def render(node: ExpLinkNode, text: String): LinkRenderer.Rendering = delegate.render(node, text)
137+
}
138+
139+
class SinglePageAnchorLinkSerializer(ctx: Writer.Context) extends ToHtmlSerializerPlugin {
140+
def visit(node: Node, visitor: Visitor, printer: Printer): Boolean = node match {
141+
case anchor: AnchorLinkSuperNode =>
142+
val name = s"${ctx.page.path}~${anchor.name}"
143+
printer.print(s"""<a href="#$name" name="$name" class="anchor"><span class="anchor-link"></span></a><span class="header-title">""")
144+
anchor.getChildren.asScala.foreach(_.accept(visitor))
145+
printer.print("</span>")
146+
true
147+
case _ => false
148+
}
149+
}
150+
151+
class SinglePageToHtmlSerializer(ctx: Writer.Context) extends ToHtmlSerializer(
152+
defaultLinks(ctx),
153+
Writer.defaultVerbatims.asJava,
154+
defaultPlugins(defaultDirectives).map(p => p(ctx)).asJava
155+
) {
156+
157+
override def visit(node: HeaderNode): Unit = {
158+
val offsetDepth = node.getLevel + ctx.location.depth
159+
160+
def visitHeaderChildren(node: HeaderNode): Unit = {
161+
node.getChildren.asScala.toList match {
162+
case List(anchorLink: AnchorLinkNode, text: TextNode) =>
163+
linkRenderer.render(anchorLink)
164+
printer.print("""<span class="header-title">""")
165+
text.accept(this)
166+
printer.print("</span>")
167+
case List(anchorLink: AnchorLinkSuperNode) =>
168+
anchorLink.accept(this)
169+
case other =>
170+
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)
171+
visitChildren(node)
172+
}
173+
}
174+
if (offsetDepth > 6) {
175+
printer.println().print("<div class=\"h").print(offsetDepth.toString).print("\">")
176+
visitHeaderChildren(node)
177+
printer.print("</div>").println()
178+
} else {
179+
printer.println().print("<h").print(offsetDepth.toString).print('>')
180+
visitHeaderChildren(node)
181+
printer.print("</h").print(offsetDepth.toString).print('>').println()
182+
}
183+
}
184+
185+
}
186+
187+
class SinglePageTableOfContents(maxDepth: Int = 6, maxExpandDepth: Option[Int] = None) extends TableOfContents(true, true, false, maxDepth, maxExpandDepth) {
188+
override protected def link[A <: Linkable](base: String, linkable: A, active: Option[Tree.Location[Page]]): Node = {
189+
val path = linkable match {
190+
case page: Page => page.path
191+
case header: Header => header.path.replace('#', '~')
192+
}
193+
194+
new ExpLinkNode("", "#" + base + path, linkable.label)
195+
}
196+
}
197+
198+
}

core/src/main/scala/com/lightbend/paradox/markdown/TableOfContents.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ class TableOfContents(pages: Boolean = true, headers: Boolean = true, ordered: B
146146
}
147147
}
148148

149-
private def link[A <: Linkable](base: String, linkable: A, active: Option[Location[Page]]): Node = {
149+
protected def link[A <: Linkable](base: String, linkable: A, active: Option[Location[Page]]): Node = {
150150
val (path, classAttributes) = linkable match {
151151
case page: Page =>
152152
val isActive = active.exists(_.tree.label.path == page.path)

core/src/main/scala/com/lightbend/paradox/template/PageTemplate.scala

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,28 +28,50 @@ import collection.concurrent.TrieMap
2828
/**
2929
* Page template writer.
3030
*/
31-
class PageTemplate(directory: File, val defaultName: String = "page", startDelimiter: Char = '$', stopDelimiter: Char = '$') {
31+
class PageTemplate(directory: File, val defaultName: String = "page", val defaultSingleName: String = "single", val defaultPrintName: String = "print", startDelimiter: Char = '$', stopDelimiter: Char = '$') {
3232
private val templates = new STRawGroupDir(directory.getAbsolutePath, startDelimiter, stopDelimiter)
3333

3434
/**
3535
* Write a templated page to the target file.
3636
*/
3737
def write(name: String, contents: PageTemplate.Contents, target: File): File = {
3838
import scala.collection.JavaConverters._
39+
write(name, target) { t =>
40+
// TODO, only load page properties, not global ones
41+
for (content <- contents.getProperties.asScala.filterNot(_._1.contains("."))) { t.add(content._1, content._2) }
42+
t.add("page", contents)
43+
}
44+
}
45+
46+
/**
47+
* Write all the templated pages to the target file.
48+
*/
49+
def writeSingle(name: String, firstPage: PageTemplate.Contents, contents: Seq[PageTemplate.Contents], target: File): File = {
50+
import scala.collection.JavaConverters._
51+
write(name, target) { t =>
52+
t.add("page", firstPage)
53+
t.add("pages", contents.asJava)
54+
}
55+
}
56+
57+
def writePrintCover(name: String, page: PageTemplate.Contents, target: File): File = {
58+
write(name, target) { t =>
59+
t.add("page", page)
60+
}
61+
}
3962

63+
private def write(name: String, target: File)(addVars: ST => ST): File = {
4064
val template = Option(templates.getInstanceOf(name)) match {
41-
case Some(t) => // TODO, only load page properties, not global ones
42-
for (content <- contents.getProperties.asScala.filterNot(_._1.contains("."))) { t.add(content._1, content._2) }
43-
t.add("page", contents)
65+
case Some(t) =>
66+
addVars(t)
4467
case None => sys.error(s"StringTemplate '$name' was not found for '$target'. Create a template or set a theme that contains one.")
4568
}
4669
val osWriter = new OutputStreamWriter(new FileOutputStream(target), StandardCharsets.UTF_8)
4770
val noIndentWriter = new NoIndentWriter(osWriter)
4871
template.write(noIndentWriter)
49-
osWriter.close
72+
osWriter.close()
5073
target
5174
}
52-
5375
}
5476

5577
object PageTemplate {
@@ -69,6 +91,7 @@ object PageTemplate {
6991
def getToc: String
7092
def getSource_url: String
7193
def getProperties: JMap[String, String]
94+
def getPath: String
7295
}
7396

7497
/**

docs/src/main/paradox/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ Paradox is a Markdown documentation tool for software projects.
1717
* [Groups](groups.md)
1818
* [Customization](customization/index.md)
1919
* [Validation](validation.md)
20+
* [Single Page HTML/PDF](single.md)
2021

2122
@@@

0 commit comments

Comments
 (0)