├── .github ├── mergify.yml ├── scala-steward.conf ├── workflows │ ├── publish.yml │ ├── release-drafter.yml │ ├── dependency-graph.yml │ └── build-test.yml └── dependabot.yml ├── src ├── test │ ├── resources │ │ ├── example │ │ │ ├── _Sidebar.md │ │ │ ├── docs │ │ │ │ ├── sub │ │ │ │ │ ├── SubFoo1.md │ │ │ │ │ ├── SubFoo2.md │ │ │ │ │ └── index.toc │ │ │ │ ├── Foo.md │ │ │ │ └── index.toc │ │ │ ├── _Breadcrumbs.md │ │ │ ├── Home.md │ │ │ └── index.toc │ │ ├── code │ │ │ ├── whole.txt │ │ │ └── sample.txt │ │ ├── file-placeholder │ │ └── example-jar-repo.jar │ └── scala │ │ └── play │ │ └── doc │ │ ├── PageIndexSpec.scala │ │ ├── FileRepositorySpec.scala │ │ └── PlayDocSpec.scala └── main │ ├── twirl │ └── play │ │ └── doc │ │ ├── nextLink.scala.html │ │ ├── sidebar.scala.html │ │ ├── toc.scala.html │ │ └── breadcrumbs.scala.html │ ├── java │ └── play │ │ └── doc │ │ ├── TocNode.java │ │ ├── TocParser.java │ │ ├── VariableNode.java │ │ ├── VariableParser.java │ │ ├── CodeReferenceNode.java │ │ └── CodeReferenceParser.java │ └── scala │ └── play │ └── doc │ ├── PrettifyVerbatimSerializer.scala │ ├── PlayDocTemplates.scala │ ├── FileRepository.scala │ ├── PageIndex.scala │ └── PlayDoc.scala ├── .git-blame-ignore-revs ├── .gitignore ├── project ├── build.properties ├── plugins.sbt └── Omnidoc.scala ├── .scalafmt.conf ├── README.md └── LICENSE /.github/mergify.yml: -------------------------------------------------------------------------------- 1 | extends: .github 2 | -------------------------------------------------------------------------------- /src/test/resources/example/_Sidebar.md: -------------------------------------------------------------------------------- 1 | Sidebar -------------------------------------------------------------------------------- /src/test/resources/example/docs/sub/SubFoo1.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/example/docs/sub/SubFoo2.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/example/_Breadcrumbs.md: -------------------------------------------------------------------------------- 1 | Breadcrumbs -------------------------------------------------------------------------------- /src/test/resources/example/docs/Foo.md: -------------------------------------------------------------------------------- 1 | Some markdown -------------------------------------------------------------------------------- /src/test/resources/code/whole.txt: -------------------------------------------------------------------------------- 1 | This is the whole file -------------------------------------------------------------------------------- /src/test/resources/example/Home.md: -------------------------------------------------------------------------------- 1 | # Documentation Home 2 | 3 | @toc@ -------------------------------------------------------------------------------- /src/test/resources/example/docs/index.toc: -------------------------------------------------------------------------------- 1 | Foo:Foo Page;next=SubFoo2 2 | sub:Sub Section -------------------------------------------------------------------------------- /src/test/resources/example/index.toc: -------------------------------------------------------------------------------- 1 | Home:Documentation Home 2 | docs:Sub Documentation -------------------------------------------------------------------------------- /src/test/resources/example/docs/sub/index.toc: -------------------------------------------------------------------------------- 1 | SubFoo1:Sub Foo Page 1 2 | SubFoo2:Sub Foo Page 2 -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.9.7 2 | cfcac5d933ec6c7f96f02d93a133f74d25dc2dd6 3 | -------------------------------------------------------------------------------- /src/test/resources/file-placeholder: -------------------------------------------------------------------------------- 1 | This file is for finding the resources (or test-classes) directory from the classpath. -------------------------------------------------------------------------------- /src/test/resources/example-jar-repo.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playframework/play-doc/HEAD/src/test/resources/example-jar-repo.jar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | project/project 2 | project/target 3 | target 4 | .idea 5 | .bsp/ 6 | .vscode 7 | .bloop 8 | .metals 9 | project/metals.sbt 10 | project/.bloop 11 | -------------------------------------------------------------------------------- /src/main/twirl/play/doc/nextLink.scala.html: -------------------------------------------------------------------------------- 1 | @(toc: play.doc.TocTree, next: String) 2 |

@next: @toc.title

-------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | # Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 2 | 3 | sbt.version=1.11.7 4 | -------------------------------------------------------------------------------- /.github/scala-steward.conf: -------------------------------------------------------------------------------- 1 | commits.message = "${artifactName} ${nextVersion} (was ${currentVersion})" 2 | 3 | pullRequests.grouping = [ 4 | { name = "patches", "title" = "Patch updates", "filter" = [{"version" = "patch"}] } 5 | ] 6 | -------------------------------------------------------------------------------- /src/main/twirl/play/doc/sidebar.scala.html: -------------------------------------------------------------------------------- 1 | @* 2 | * Renders a sidebar, given a list of table of contents sections 3 | *@ 4 | @(heirarchy: List[play.doc.Toc]) 5 | @for(toc <- heirarchy) {

@toc.title

6 | 9 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: # Snapshots 6 | - main 7 | tags: ["**"] # Releases 8 | 9 | jobs: 10 | publish-artifacts: 11 | name: Publish / Artifacts 12 | uses: playframework/.github/.github/workflows/publish.yml@v4 13 | secrets: inherit 14 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 2 | 3 | addSbtPlugin("org.playframework.twirl" % "sbt-twirl" % "2.0.9") 4 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") 5 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") 6 | addSbtPlugin("com.github.sbt" % "sbt-header" % "5.11.0") 7 | -------------------------------------------------------------------------------- /src/main/twirl/play/doc/toc.scala.html: -------------------------------------------------------------------------------- 1 | @* 2 | * Renders a table of contents 3 | *@ 4 | @(toc: play.doc.Toc) 5 | @import play.doc.Toc 6 | @renderToc(toc: Toc) = {
    @for(node <- toc.nodes) { 7 |
  1. 8 | @node._2.title 9 | @{node match { 10 | case (_, toc: Toc) if toc.descend && toc.nodes.size > 1 => renderToc(toc) 11 | case _ => "" 12 | }}
  2. } 13 |
} 14 | @for(top <- toc.nodes.collect({ case (_, toc: play.doc.Toc) => toc })) { 15 |

@top.title

16 | @renderToc(top)} 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | target-branch: "3.0.x" 12 | commit-message: 13 | prefix: "[3.0.x] " 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | target-branch: "2.2.x" 19 | commit-message: 20 | prefix: "[2.2.x] " 21 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v6 13 | with: 14 | name: "play-doc $RESOLVED_VERSION" 15 | config-name: release-drafts/increasing-minor-version.yml # located in .github/ in the default branch within this or the .github repo 16 | commitish: ${{ github.ref_name }} 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | runner.dialect = scala213 2 | align.preset = true 3 | assumeStandardLibraryStripMargin = true 4 | danglingParentheses.preset = true 5 | docstrings.style = Asterisk 6 | maxColumn = 120 7 | project.git = true 8 | rewrite.rules = [ AvoidInfix, ExpandImportSelectors, RedundantParens, SortModifiers, PreferCurlyFors ] 9 | rewrite.sortModifiers.order = [ "private", "protected", "final", "sealed", "abstract", "implicit", "override", "lazy" ] 10 | spaces.inImportCurlyBraces = true # more idiomatic to include whitepsace in import x.{ yyy } 11 | trailingCommas = preserve 12 | version = 3.10.2 13 | -------------------------------------------------------------------------------- /src/main/java/play/doc/TocNode.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc; 6 | 7 | import org.pegdown.ast.AbstractNode; 8 | import org.pegdown.ast.Node; 9 | import org.pegdown.ast.Visitor; 10 | 11 | import java.util.Collections; 12 | import java.util.List; 13 | 14 | /** 15 | * A table of contents node 16 | */ 17 | public class TocNode extends AbstractNode { 18 | 19 | @Override 20 | public void accept(Visitor visitor) { 21 | visitor.visit(this); 22 | } 23 | 24 | @Override 25 | public List getChildren() { 26 | return Collections.emptyList(); 27 | } 28 | } -------------------------------------------------------------------------------- /.github/workflows/dependency-graph.yml: -------------------------------------------------------------------------------- 1 | name: Dependency Graph 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: 8 | # Only run once for latest commit per ref and cancel other (previous) runs. 9 | group: dependency-graph-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: write # this permission is needed to submit the dependency graph 14 | 15 | jobs: 16 | dependency-graph: 17 | name: Submit dependencies to GitHub 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v6 21 | with: 22 | fetch-depth: 0 23 | ref: ${{ inputs.ref }} 24 | - uses: sbt/setup-sbt@v1 25 | - uses: scalacenter/sbt-dependency-submission@v3 26 | -------------------------------------------------------------------------------- /src/test/resources/code/sample.txt: -------------------------------------------------------------------------------- 1 | 2 | #simple 3 | Snippet 4 | #simple 5 | 6 | #leading-following 7 | Leading Following 8 | #leading-following 9 | 10 | #onetwothree 11 | One Two Three 12 | #onetwothree 13 | 14 | #one 15 | One 16 | #one 17 | 18 | #indent 19 | deep 20 | shallow 21 | #indent 22 | 23 | // #otherchars // 24 | Snippet 25 | // #otherchars // 26 | 27 | #skipping 28 | skipped ###skip 29 | Snippet 30 | #skipping 31 | 32 | #skipmultiple 33 | // ###skip: 1 34 | skipped 35 | Snippet 36 | #skipmultiple 37 | 38 | #replacing 39 | // ###replace: Replaced 40 | foobar 41 | Snippet 42 | #replacing 43 | 44 | #replacelimited 45 | // ###replace: Replaced### 46 | foobar 47 | #replacelimited 48 | 49 | #inserting 50 | // ###insert: Inserted 51 | Snippet 52 | #inserting 53 | 54 | #insertlimited 55 | // ###insert: Inserted### 56 | #insertlimited -------------------------------------------------------------------------------- /src/main/java/play/doc/TocParser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc; 6 | 7 | import org.parboiled.Rule; 8 | import org.pegdown.Parser; 9 | import org.pegdown.plugins.BlockPluginParser; 10 | 11 | /** 12 | * Parser for parsing variables that are substituted with arbitrary text. 13 | */ 14 | public class TocParser extends Parser implements BlockPluginParser { 15 | 16 | public TocParser() { 17 | super(ALL, 1000l, DefaultParseRunnerProvider); 18 | } 19 | 20 | public Rule TocRule() { 21 | return NodeSequence( 22 | "@toc@", 23 | push(new TocNode()) 24 | ); 25 | } 26 | 27 | @Override 28 | public Rule[] blockPluginRules() { 29 | return new Rule[] {TocRule()}; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/twirl/play/doc/breadcrumbs.scala.html: -------------------------------------------------------------------------------- 1 | @* 2 | * Renders breadcrumbs, given a list of table of contents sections. 3 | * 4 | * Does not render the last element, as that's the page you're on. 5 | * 6 | * This also effects on Google searches because the structured data 7 | * is seen as significant: 8 | * 9 | * https://developers.google.com/structured-data/breadcrumbs 10 | *@ 11 | @(hierarchy: List[play.doc.Toc]) 12 | 13 | -------------------------------------------------------------------------------- /src/main/java/play/doc/VariableNode.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc; 6 | 7 | import org.pegdown.ast.AbstractNode; 8 | import org.pegdown.ast.Node; 9 | import org.pegdown.ast.Visitor; 10 | 11 | import java.util.Collections; 12 | import java.util.List; 13 | 14 | /** 15 | * A dynamic variable node 16 | */ 17 | public class VariableNode extends AbstractNode { 18 | 19 | private final String name; 20 | 21 | public VariableNode(String name) { 22 | this.name = name; 23 | } 24 | 25 | public String getName() { 26 | return name; 27 | } 28 | 29 | @Override 30 | public void accept(Visitor visitor) { 31 | visitor.visit(this); 32 | } 33 | 34 | @Override 35 | public List getChildren() { 36 | return Collections.emptyList(); 37 | } 38 | } -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | pull_request: 5 | 6 | push: 7 | branches: 8 | - main # Check branch after merge 9 | 10 | concurrency: 11 | # Only run once for latest commit per ref and cancel other (previous) runs. 12 | group: ci-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | check-code-style: 17 | name: Code Style 18 | uses: playframework/.github/.github/workflows/cmd.yml@v4 19 | with: 20 | cmd: sbt validateCode 21 | 22 | tests: 23 | name: Tests 24 | needs: 25 | - "check-code-style" 26 | uses: playframework/.github/.github/workflows/cmd.yml@v4 27 | with: 28 | java: 21, 17 29 | scala: 2.12.x, 2.13.x, 3.x 30 | cmd: sbt ++$MATRIX_SCALA test 31 | 32 | finish: 33 | name: Finish 34 | if: github.event_name == 'pull_request' 35 | needs: # Should be last 36 | - "tests" 37 | uses: playframework/.github/.github/workflows/rtm.yml@v4 38 | -------------------------------------------------------------------------------- /src/main/java/play/doc/VariableParser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc; 6 | 7 | import org.parboiled.Rule; 8 | import org.pegdown.Parser; 9 | import org.pegdown.plugins.InlinePluginParser; 10 | 11 | /** 12 | * Parser for parsing variables that are substituted with arbitrary text. 13 | */ 14 | public class VariableParser extends Parser implements InlinePluginParser { 15 | 16 | final String name; 17 | 18 | public VariableParser(String name) { 19 | super(ALL, 1000l, DefaultParseRunnerProvider); 20 | this.name = name; 21 | } 22 | 23 | public Rule VariableRule() { 24 | return NodeSequence( 25 | name, 26 | push(new VariableNode(name)) 27 | ); 28 | } 29 | 30 | @Override 31 | public Rule[] inlinePluginRules() { 32 | return new Rule[] {VariableRule()}; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/play/doc/CodeReferenceNode.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc; 6 | 7 | import org.pegdown.ast.AbstractNode; 8 | import org.pegdown.ast.Node; 9 | import org.pegdown.ast.Visitor; 10 | 11 | import java.util.Collections; 12 | import java.util.List; 13 | 14 | /** 15 | * A code reference node 16 | */ 17 | public class CodeReferenceNode extends AbstractNode { 18 | 19 | private final String label; 20 | private final String source; 21 | 22 | public CodeReferenceNode(String label, String source) { 23 | this.label = label; 24 | this.source = source; 25 | } 26 | 27 | public String getLabel() { 28 | return label; 29 | } 30 | 31 | public String getSource() { 32 | return source; 33 | } 34 | 35 | @Override 36 | public void accept(Visitor visitor) { 37 | visitor.visit(this); 38 | } 39 | 40 | @Override 41 | public List getChildren() { 42 | return Collections.emptyList(); 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/scala/play/doc/PrettifyVerbatimSerializer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | import org.pegdown.Printer 8 | import org.pegdown.VerbatimSerializer 9 | import org.pegdown.ast.VerbatimNode 10 | import org.parboiled.common.StringUtils 11 | 12 | /** 13 | * Prints verbatim nodes in such a format that Google Code Prettify will work with them 14 | */ 15 | object PrettifyVerbatimSerializer extends VerbatimSerializer { 16 | def serialize(node: VerbatimNode, printer: Printer) = { 17 | def printAttribute(name: String, value: String): Unit = { 18 | printer.print(' ').print(name).print('=').print('"').print(value).print('"') 19 | } 20 | 21 | printer 22 | .println() 23 | .print("") 30 | 31 | val text = node.getText 32 | // print HTML breaks for all initial newlines 33 | text.takeWhile(_ == '\n').foreach { _ => printer.print("
") } 34 | 35 | printer.printEncoded(text.dropWhile(_ == '\n')) 36 | printer.print(""); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/play/doc/CodeReferenceParser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc; 6 | 7 | import org.parboiled.Rule; 8 | import org.parboiled.support.StringVar; 9 | import org.pegdown.Parser; 10 | import org.pegdown.plugins.BlockPluginParser; 11 | 12 | /** 13 | * Parboiled parser for code references in markdown. 14 | * 15 | * Implemented in Java because this is necessary for parboiled enhancement to work. 16 | */ 17 | public class CodeReferenceParser extends Parser implements BlockPluginParser { 18 | 19 | public CodeReferenceParser() { 20 | super(ALL, 1000l, DefaultParseRunnerProvider); 21 | } 22 | 23 | public Rule CodeReference() { 24 | StringVar label = new StringVar(); 25 | StringVar source = new StringVar(); 26 | return NodeSequence( 27 | '@', 28 | '[', 29 | Sequence(ZeroOrMore(TestNot(']'), ANY), label.set(match())), 30 | ']', 31 | '(', 32 | Sequence(OneOrMore(TestNot(')'), ANY), source.set(match())), 33 | ')', 34 | push(new CodeReferenceNode(label.get(), source.get())), 35 | Newline() 36 | ); 37 | } 38 | 39 | @Override 40 | public Rule[] blockPluginRules() { 41 | return new Rule[] {CodeReference()}; 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/main/scala/play/doc/PlayDocTemplates.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | /** 8 | * Templates for rendering Play documentation snippets. 9 | */ 10 | trait PlayDocTemplates { 11 | 12 | /** 13 | * Render the next link. 14 | * 15 | * @param toc 16 | * The table of contents. 17 | * @return 18 | * The next link. 19 | */ 20 | def nextLink(toc: TocTree): String 21 | 22 | /** 23 | * Render a set of next links. 24 | * 25 | * @param toc 26 | * The list of table of contents. 27 | * @return 28 | * The next links. 29 | */ 30 | def nextLinks(toc: List[TocTree]) = toc.map(nextLink).mkString("") 31 | 32 | /** 33 | * Render the sidebar. 34 | * 35 | * @param hierarchy 36 | * The hierarchy to render in the sidebar. 37 | * @return 38 | * The sidebar. 39 | */ 40 | def sidebar(hierarchy: List[Toc]): String 41 | 42 | /** 43 | * @param hierarchy 44 | * The hierarchy to render in the breadcrumbs. 45 | * @return 46 | */ 47 | def breadcrumbs(hierarchy: List[Toc]): String 48 | 49 | /** 50 | * Render a table of contents. 51 | * 52 | * @param toc 53 | * The table of contents to render. 54 | * @return 55 | * The table of contents. 56 | */ 57 | def toc(toc: Toc): String 58 | } 59 | 60 | class TranslatedPlayDocTemplates(nextText: String) extends PlayDocTemplates { 61 | override def nextLink(toc: TocTree): String = play.doc.html.nextLink(toc, nextText).body 62 | override def sidebar(heirarchy: List[Toc]): String = play.doc.html.sidebar(heirarchy).body 63 | override def breadcrumbs(hierarchy: List[Toc]): String = play.doc.html.breadcrumbs(hierarchy).body 64 | override def toc(toc: Toc): String = play.doc.html.toc(toc).body 65 | } 66 | 67 | object PlayDocTemplates extends TranslatedPlayDocTemplates("Next") 68 | -------------------------------------------------------------------------------- /project/Omnidoc.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | import sbt._ 6 | import sbt.Keys._ 7 | import sbt.Package.ManifestAttributes 8 | 9 | /** 10 | * This AutoPlugin adds the `Omnidoc-Source-URL` key on the MANIFEST.MF of artifact-sources.jar so later Omnidoc can use 11 | * that value to link scaladocs to GitHub sources. 12 | */ 13 | object Omnidoc extends AutoPlugin { 14 | 15 | object autoImport { 16 | lazy val omnidocSnapshotBranch = settingKey[String]("Git branch for development versions") 17 | lazy val omnidocPathPrefix = settingKey[String]("Prefix before source directory paths") 18 | lazy val omnidocSourceUrl = settingKey[Option[String]]("Source URL for scaladoc linking") 19 | } 20 | 21 | val repoName = "play-doc" 22 | 23 | val omnidocGithubRepo: Option[String] = Some(s"playframework/${repoName}") 24 | 25 | val omnidocTagPrefix: Option[String] = Some("") 26 | 27 | val SourceUrlKey = "Omnidoc-Source-URL" 28 | 29 | override def requires = sbt.plugins.JvmPlugin 30 | 31 | override def trigger = noTrigger 32 | 33 | import autoImport.* 34 | 35 | override def projectSettings = Seq( 36 | omnidocSourceUrl := omnidocGithubRepo.map { repo => 37 | val development: String = (omnidocSnapshotBranch ?? "main").value 38 | val tagged: String = omnidocTagPrefix.getOrElse("v") + version.value 39 | val tree: String = if (isSnapshot.value) development else tagged 40 | val prefix: String = "/" + (omnidocPathPrefix ?? "").value 41 | val path: String = { 42 | val buildDir: File = (ThisBuild / baseDirectory).value 43 | val projDir: File = baseDirectory.value 44 | val rel: Option[String] = IO.relativize(buildDir, projDir) 45 | rel match { 46 | case None if buildDir == projDir => "" // Same dir (sbt 0.13) 47 | case Some("") => "" // Same dir (sbt 1.0) 48 | case Some(childDir) => prefix + childDir // Child dir 49 | case None => "" // Disjoint dirs (Rich: I'm not sure if this can happen) 50 | } 51 | } 52 | s"https://github.com/${repo}/tree/${tree}${path}" 53 | }, 54 | Compile / packageSrc / packageOptions ++= omnidocSourceUrl.value.toSeq.map { url => 55 | ManifestAttributes(SourceUrlKey -> url) 56 | } 57 | ) 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/test/scala/play/doc/PageIndexSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | import java.io.File 8 | 9 | import org.specs2.mutable.Specification 10 | 11 | class PageIndexSpec extends Specification { 12 | def fileFromClasspath(name: String) = new File(Thread.currentThread.getContextClassLoader.getResource(name).toURI) 13 | val repo = new FilesystemRepository(fileFromClasspath("file-placeholder").getParentFile) 14 | def maybeIndex = PageIndex.parseFrom(repo, "Home", Some("example")) 15 | def index = maybeIndex.getOrElse(new PageIndex(Toc("", "", Nil))) 16 | 17 | "Page Index " should { 18 | "parse the index" in { 19 | maybeIndex must beSome(anInstanceOf[PageIndex]) 20 | } 21 | 22 | "provide access to pages" in { 23 | "top level" in { 24 | index.get("Home") must beSome[Page].like { case page => 25 | page.page must_== "Home" 26 | page.path must beSome("example") 27 | page.title must_== "Documentation Home" 28 | } 29 | } 30 | "first level" in { 31 | index.get("Foo") must beSome[Page].like { case page => 32 | page.page must_== "Foo" 33 | page.path must beSome("example/docs") 34 | page.title must_== "Foo Page" 35 | } 36 | } 37 | "deep" in { 38 | index.get("SubFoo1") must beSome[Page].like { case page => 39 | page.page must_== "SubFoo1" 40 | page.path must beSome("example/docs/sub") 41 | page.title must_== "Sub Foo Page 1" 42 | } 43 | index.get("SubFoo2") must beSome[Page].like { case page => 44 | page.page must_== "SubFoo2" 45 | page.path must beSome("example/docs/sub") 46 | page.title must_== "Sub Foo Page 2" 47 | } 48 | } 49 | "non existent" in { 50 | index.get("NotExists") must beNone 51 | } 52 | } 53 | 54 | "provide a table of contents" in { 55 | index.toc.nodes.collectFirst { case ("Home", n) => n } must beSome(TocPage("Home", "Documentation Home", None)) 56 | index.toc.nodes.collectFirst { case ("docs", n) => n } must beSome[TocTree].like { case toc: Toc => 57 | toc.title must_== "Sub Documentation" 58 | toc.nodes.collectFirst { case ("Foo", n) => n } must beSome(TocPage("Foo", "Foo Page", Some(List("SubFoo2")))) 59 | toc.nodes.collectFirst { case ("sub", n) => n } must beSome[TocTree].like { case toc: Toc => 60 | toc.title must_== "Sub Section" 61 | toc.nodes.collectFirst { case ("SubFoo1", n) => n } must beSome( 62 | TocPage("SubFoo1", "Sub Foo Page 1", None) 63 | ) 64 | toc.nodes.collectFirst { case ("SubFoo2", n) => n } must beSome( 65 | TocPage("SubFoo2", "Sub Foo Page 2", None) 66 | ) 67 | } 68 | } 69 | } 70 | 71 | "provide navigation" in { 72 | index.get("SubFoo1") must beSome[Page].like { case page => 73 | page.nav.size must_== 3 74 | page.nav(0).title must_== "Sub Section" 75 | page.nav(1).title must_== "Sub Documentation" 76 | page.nav(2).title must_== "Home" 77 | } 78 | } 79 | 80 | "provide the next page" in { 81 | index.get("Home").flatMap(_.next) must beSome[TocTree].like { case toc: Toc => 82 | toc.name must_== "docs" 83 | } 84 | index.get("Foo").flatMap(_.next) must beSome[TocTree].like { case toc: TocPage => 85 | toc.page must_== "SubFoo2" 86 | } 87 | index.get("SubFoo1").flatMap(_.next) must beSome[TocTree].like { case toc: TocPage => 88 | toc.page must_== "SubFoo2" 89 | } 90 | index.get("SubFoo2").flatMap(_.next) must beNone 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/test/scala/play/doc/FileRepositorySpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | import org.specs2.mutable.Specification 8 | import java.io.File 9 | import org.apache.commons.io.IOUtils 10 | import java.util.jar.JarFile 11 | 12 | class FileRepositorySpec extends Specification { 13 | def fileFromClasspath(name: String) = new File(Thread.currentThread.getContextClassLoader.getResource(name).toURI) 14 | def loadFileFromRepo(repo: FileRepository, path: String) = repo.loadFile(path)(IOUtils.toString(_, "utf-8")) 15 | def handleFileFromRepo(repo: FileRepository, path: String) = 16 | repo.handleFile(path) { handle => 17 | val result = (handle.name, handle.size, IOUtils.toString(handle.is, "utf-8")) 18 | handle.close() 19 | result 20 | } 21 | 22 | "FilesystemRepository" should { 23 | val repo = new FilesystemRepository(fileFromClasspath("file-placeholder").getParentFile) 24 | def loadFile(path: String) = loadFileFromRepo(repo, path) 25 | def handleFile(path: String) = handleFileFromRepo(repo, path) 26 | import repo.findFileWithName 27 | 28 | "load a file" in { 29 | loadFile("example/docs/Foo.md") must beSome("Some markdown") 30 | } 31 | 32 | "return none when file not found" in { 33 | loadFile("example/NotFound.md") must beNone 34 | } 35 | 36 | "return none when file is a directory" in { 37 | loadFile("example/docs") must beNone 38 | } 39 | 40 | "handle a file" in { 41 | handleFile("example/docs/Foo.md") must beSome(("Foo.md", 13, "Some markdown")) 42 | } 43 | 44 | "handle a missing file" in { 45 | handleFile("example/NotFound.md") must beNone 46 | } 47 | 48 | "find a file with a name" in { 49 | findFileWithName("Foo.md") must beSome("example/docs/Foo.md") 50 | } 51 | 52 | "return none when a file with a name is not found" in { 53 | findFileWithName("NotFound.md") must beNone 54 | } 55 | 56 | "return none when a file with a name is a directory" in { 57 | findFileWithName("docs") must beNone 58 | } 59 | } 60 | 61 | "JarRepository" should { 62 | def withJarRepo[T](base: Option[String])(block: JarRepository => T): T = { 63 | val repo = new JarRepository(new JarFile(fileFromClasspath("example-jar-repo.jar")), base) 64 | try { 65 | block(repo) 66 | } finally { 67 | repo.close() 68 | } 69 | } 70 | 71 | def loadFile(path: String) = withJarRepo(None)(loadFileFromRepo(_, path)) 72 | def handleFile(path: String) = withJarRepo(None)(handleFileFromRepo(_, path)) 73 | def findFileWithName(name: String) = withJarRepo(None)(_.findFileWithName(name)) 74 | 75 | "load a file" in { 76 | loadFile("example/docs/Foo.md") must beSome("Some markdown") 77 | } 78 | 79 | "return none when file not found" in { 80 | loadFile("example/NotFound.md") must beNone 81 | } 82 | 83 | "return none when file is a directory" in { 84 | loadFile("example/docs") must beNone 85 | } 86 | 87 | "handle a file" in { 88 | handleFile("example/docs/Foo.md") must beSome(("Foo.md", 13, "Some markdown")) 89 | } 90 | 91 | "handle a missing file" in { 92 | handleFile("example/NotFound.md") must beNone 93 | } 94 | 95 | "find a file with a name" in { 96 | findFileWithName("Foo.md") must beSome("example/docs/Foo.md") 97 | } 98 | 99 | "return none when a file with a name is not found" in { 100 | findFileWithName("NotFound.md") must beNone 101 | } 102 | 103 | "return none when a file with a name is a directory" in { 104 | findFileWithName("docs") must beNone 105 | } 106 | 107 | "find a file with a name in base directory" in { 108 | withJarRepo(Some("example"))(_.findFileWithName("Foo.md")) must beSome("docs/Foo.md") 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # play-doc 4 | 5 | [![Twitter Follow](https://img.shields.io/twitter/follow/playframework?label=follow&style=flat&logo=twitter&color=brightgreen)](https://twitter.com/playframework) 6 | [![Discord](https://img.shields.io/discord/931647755942776882?logo=discord&logoColor=white)](https://discord.gg/g5s2vtZ4Fa) 7 | [![GitHub Discussions](https://img.shields.io/github/discussions/playframework/playframework?&logo=github&color=brightgreen)](https://github.com/playframework/playframework/discussions) 8 | [![StackOverflow](https://img.shields.io/static/v1?label=stackoverflow&logo=stackoverflow&logoColor=fe7a16&color=brightgreen&message=playframework)](https://stackoverflow.com/tags/playframework) 9 | [![YouTube](https://img.shields.io/youtube/channel/views/UCRp6QDm5SDjbIuisUpxV9cg?label=watch&logo=youtube&style=flat&color=brightgreen&logoColor=ff0000)](https://www.youtube.com/channel/UCRp6QDm5SDjbIuisUpxV9cg) 10 | [![Twitch Status](https://img.shields.io/twitch/status/playframework?logo=twitch&logoColor=white&color=brightgreen&label=live%20stream)](https://www.twitch.tv/playframework) 11 | [![OpenCollective](https://img.shields.io/opencollective/all/playframework?label=financial%20contributors&logo=open-collective)](https://opencollective.com/playframework) 12 | 13 | [![Build Status](https://github.com/playframework/play-doc/actions/workflows/build-test.yml/badge.svg)](https://github.com/playframework/play-doc/actions/workflows/build-test.yml) 14 | [![Maven](https://img.shields.io/maven-central/v/org.playframework/play-doc_2.13.svg?logo=apache-maven)](https://mvnrepository.com/artifact/org.playframework/play-doc_2.13) 15 | [![Repository size](https://img.shields.io/github/repo-size/playframework/play-doc.svg?logo=git)](https://github.com/playframework/play-doc) 16 | [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org) 17 | [![Mergify Status](https://img.shields.io/endpoint.svg?url=https://api.mergify.com/v1/badges/playframework/play-doc&style=flat)](https://mergify.com) 18 | 19 | This project implements Plays documentation parsing and generating. Documentation is in markdown with an extension that allows code snippets to come from external files. It is used by Play to allow us to have all our documentation code snippets in external compiled and tested files, ensuring that they are accurate and up to date. 20 | 21 | ## Markdown Syntax 22 | 23 | Referencing code in an external file looks like this: 24 | 25 | ```markdown 26 | @[label](some/relative/path) 27 | ``` 28 | 29 | ## Code snippet syntax 30 | 31 | Code snippets are identified using the hash symbol prepended to the label, like this: 32 | 33 | ```scala 34 | //#label 35 | println("Hello world") 36 | //#label 37 | ``` 38 | 39 | The label can be anywhere in a line, and the code snippet will be every line between the start and end labels, exclusive. Typically the snippet might be part of a test, for example: 40 | 41 | ```scala 42 | "List" should { 43 | "support fold" in { 44 | //#fold 45 | val list = List(1, 2, 3) 46 | val sum = list.fold { (a, b) => a + b } 47 | //#fold 48 | sum must_== 6 49 | } 50 | } 51 | ``` 52 | 53 | In some rare cases, you may need to slightly modify the snippet as it stands. Typically this might be when naming conflicts exist in the code, for example, if you want to show an entire Java class with its package name, and you have multiple places in your docs where you want to do similar, you might do this: 54 | 55 | ```scala 56 | //#label 57 | //#replace package foo.bar; 58 | package foo.bar.docs.sec1; 59 | 60 | class Foo { 61 | ... 62 | } 63 | //#label 64 | ``` 65 | 66 | The supported directives are replace, skip (followed by the number of lines to skip) and insert. 67 | 68 | ## Play Version 69 | 70 | Any %PLAY_VERSION% variables will be substituted with the current version of Play. 71 | 72 | ## Releasing a new version 73 | 74 | See https://github.com/playframework/.github/blob/main/RELEASING.md 75 | -------------------------------------------------------------------------------- /src/main/scala/play/doc/FileRepository.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | import java.io.FileInputStream 8 | import java.io.File 9 | import java.io.InputStream 10 | import java.util.jar.JarFile 11 | import java.util.zip.ZipEntry 12 | import scala.collection.JavaConverters.* 13 | 14 | /** 15 | * Access to file data, provided to the handler when `handleFile` is called. 16 | * 17 | * @param name 18 | * The name of the file. 19 | * @param size 20 | * The size of the file in bytes. 21 | * @param is 22 | * A stream with the file data. 23 | * @param close 24 | * Used by the handler to close the file when the handler is finished. 25 | */ 26 | case class FileHandle(name: String, size: Long, is: InputStream, close: () => Unit) 27 | 28 | /** 29 | * Repository for loading files 30 | */ 31 | trait FileRepository { 32 | 33 | /** 34 | * Load a file using the given loader. If the file is found then the file will be opened and loader will be called 35 | * with its content. The file will be closed automatically when loader returns a value or throws an exception. 36 | * 37 | * @param path 38 | * The path of the file to load 39 | * @param loader 40 | * The loader to load the file 41 | * @return 42 | * The file, as loaded by the loader, or None if the doesn't exist 43 | */ 44 | def loadFile[A](path: String)(loader: InputStream => A): Option[A] 45 | 46 | /** 47 | * Load a file using the given handler. If the file is found then the file will be opened and handler will be called 48 | * with the file's handle. The handler must call the close method on the handle to ensure that the file is closed 49 | * properly. 50 | * 51 | * @param path 52 | * The path of the file to load 53 | * @param handler 54 | * The handler to handle the file 55 | * @return 56 | * The file, as loaded by the loader, or None if the doesn't exist 57 | */ 58 | def handleFile[A](path: String)(handler: FileHandle => A): Option[A] 59 | 60 | /** 61 | * Find a file with the given name. The repositories directory structure is searched, and the path of the first file 62 | * found with that name is returned. 63 | * 64 | * @param name 65 | * The name of the file to find 66 | * @return 67 | * The path of the file, or None if it couldn't be found 68 | */ 69 | def findFileWithName(name: String): Option[String] 70 | } 71 | 72 | /** 73 | * Simple filesystem implementation of the FileRepository 74 | * 75 | * @param base 76 | * The base dir of the file 77 | */ 78 | class FilesystemRepository(base: File) extends FileRepository { 79 | private def cleanUp[A](loader: InputStream => A)(is: InputStream) = { 80 | try { 81 | loader(is) 82 | } finally { 83 | is.close() 84 | } 85 | } 86 | 87 | private def getFile(path: String): Option[File] = { 88 | val file = new File(base, path) 89 | if (file.exists() && file.isFile && file.canRead) Some(file) else None 90 | } 91 | 92 | def loadFile[A](path: String)(loader: InputStream => A) = { 93 | getFile(path).map { file => 94 | val is = new FileInputStream(file) 95 | cleanUp(loader)(is) 96 | } 97 | } 98 | 99 | def handleFile[A](path: String)(handler: FileHandle => A) = { 100 | getFile(path).map { file => 101 | val is = new FileInputStream(file) 102 | val handle = FileHandle(file.getName, file.length, is, () => is.close()) 103 | handler(handle) 104 | } 105 | } 106 | 107 | def findFileWithName(name: String) = { 108 | def findFile(name: String)(dir: File): Option[File] = { 109 | dir.listFiles().find(file => file.isFile && file.getName.equalsIgnoreCase(name)).orElse { 110 | dir.listFiles().filter(_.isDirectory).collectFirst(Function.unlift(findFile(name))) 111 | } 112 | } 113 | findFile(name)(base).map(_.getAbsolutePath.drop(base.getAbsolutePath.size + 1)) 114 | } 115 | 116 | override def toString(): String = s"FilesystemRepository($base)" 117 | } 118 | 119 | /** 120 | * Jar file implementation of the repository 121 | */ 122 | class JarRepository(jarFile: JarFile, base: Option[String] = None) extends FileRepository { 123 | private val PathSeparator = "/" 124 | private val basePrefix = base.map(_ + PathSeparator).getOrElse("") 125 | 126 | def getEntry(path: String): Option[(ZipEntry, InputStream)] = { 127 | Option(jarFile.getEntry(basePrefix + path)).flatMap { entry => 128 | Option(jarFile.getInputStream(entry)).map(is => (entry, is)) 129 | } 130 | } 131 | 132 | def loadFile[A](path: String)(loader: InputStream => A) = { 133 | getEntry(path).filterNot(_._1.isDirectory).map { case (_, is) => loader(is) } 134 | } 135 | 136 | def handleFile[A](path: String)(handler: FileHandle => A) = { 137 | getEntry(path).map { case (entry, is) => 138 | val handle = FileHandle(entry.getName.split(PathSeparator).last, entry.getSize, is, () => is.close()) 139 | handler(handle) 140 | } 141 | } 142 | 143 | def findFileWithName(name: String) = { 144 | def startsWith(full: String, part: String) = 145 | if (part.isEmpty) true 146 | else { 147 | val comparePart = if (full.length == part.length) full else full.take(part.length) 148 | comparePart.equalsIgnoreCase(part) 149 | } 150 | def endsWith(full: String, part: String) = 151 | if (part.isEmpty) true 152 | else { 153 | val comparePart = if (full.length == part.length) full else full.takeRight(part.length) 154 | comparePart.equalsIgnoreCase(part) 155 | } 156 | 157 | val slashName = PathSeparator + name 158 | val found = jarFile.entries().asScala.map(_.getName).find(n => startsWith(n, basePrefix) && endsWith(n, slashName)) 159 | found.map(_.substring(basePrefix.length)) 160 | } 161 | 162 | def close() = jarFile.close() 163 | 164 | override def toString(): String = { 165 | def toString(jar: JarFile) = { 166 | s"JarFile(name = ${jar.getName})" 167 | } 168 | s"JarRepository(jarFile = ${toString(jarFile)}, base = ${base})" 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/test/scala/play/doc/PlayDocSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | import org.specs2.mutable._ 8 | import java.io.File 9 | 10 | class PlayDocSpec extends Specification { 11 | def fileFromClasspath(name: String) = new File(Thread.currentThread.getContextClassLoader.getResource(name).toURI) 12 | val repo = new FilesystemRepository(fileFromClasspath("file-placeholder").getParentFile) 13 | val oldRenderer = new PlayDoc(repo, repo, "resources", "2.1.3", None, new TranslatedPlayDocTemplates("Next"), None) 14 | 15 | val renderer = 16 | new PlayDoc( 17 | repo, 18 | repo, 19 | "resources", 20 | "2.4.0", 21 | PageIndex.parseFrom(repo, "Home", Some("example")), 22 | new TranslatedPlayDocTemplates("Next"), 23 | None 24 | ) 25 | 26 | "code snippet handling" should { 27 | def test(label: String, rendered: String, file: String = "code/sample.txt") = { 28 | oldRenderer.render(s"@[$label]($file)") must_== 29 | s"""
$rendered
""" 30 | } 31 | 32 | def failTest(label: String) = { 33 | oldRenderer.render("@[" + label + "](code/sample.txt)") must_== 34 | """Unable to find label """ + label + """ in source file """ + "code/sample.txt" 35 | } 36 | 37 | "allow extracting code snippets" in test("simple", "Snippet") 38 | 39 | "allow extracting code snippets using string that exists as substring elsewhere" in test("one", "One") 40 | "allow extracting code snippets using string as full string" in test( 41 | "onetwothree", 42 | "One Two Three" 43 | ) // paired with previous test 44 | "fail on substring code snippets using string as trailing" in failTest("three") // paired with previous test 45 | 46 | "fail on substring with no full string match" in failTest("leading") 47 | "should match on full string" in test("leading-following", "Leading Following") // paired with test for exception 48 | "fail on substring when looking for a trailing match" in failTest("following") 49 | 50 | "strip indent of shallowest line" in test("indent", " deep\nshallow") 51 | "ignore other characters on label delimiter line" in test("otherchars", "Snippet") 52 | "allow skipping lines" in test("skipping", "Snippet") 53 | "allow skipping multiple lines" in test("skipmultiple", "Snippet") 54 | "allow replacing lines" in test("replacing", "Replaced\nSnippet") 55 | "allow replacing lines with limited text" in test("replacelimited", "Replaced") 56 | "allow inserting lines" in test("inserting", "Inserted\nSnippet") 57 | "allow inserting lines with limited text" in test("insertlimited", "Inserted") 58 | 59 | "allow including a whole file" in test("", "This is the whole file", "code/whole.txt") 60 | } 61 | 62 | "page rendering" should { 63 | "old renderer" in { 64 | "render the page and the sidebar" in { 65 | val result = oldRenderer.renderPage("Foo") 66 | result must beSome[RenderedPage].like { case RenderedPage(main, maybeSidebar, path, maybeBreadcrumbs) => 67 | main must contain("Some markdown") 68 | main must not contain "Sidebar" 69 | maybeSidebar must beSome[String].like { case sidebar => 70 | sidebar must contain("Sidebar") 71 | sidebar must not contain "Some markdown" 72 | } 73 | maybeBreadcrumbs must beSome[String].like { case breadcrumbs => 74 | breadcrumbs must contain("Breadcrumbs") 75 | breadcrumbs must not contain "Some markdown" 76 | } 77 | path must_== "example/docs/Foo.md" 78 | } 79 | } 80 | } 81 | 82 | "new renderer" in { 83 | "render the page and sidebar" in { 84 | val result = renderer.renderPage("Foo") 85 | result must beSome[RenderedPage].like { case RenderedPage(main, maybeSidebar, path, maybeBreadcrumbs) => 86 | main must contain("Some markdown") 87 | maybeSidebar must beSome[String].like { case sidebar => 88 | sidebar must contain("Documentation Home") 89 | } 90 | maybeBreadcrumbs must beSome[String].like { case breadcrumbs => 91 | breadcrumbs must contain( 92 | "Home" 93 | ) 94 | } 95 | path must_== "example/docs/Foo.md" 96 | } 97 | } 98 | } 99 | } 100 | 101 | "play version variables" should { 102 | "be substituted with the Play version" in { 103 | oldRenderer.render( 104 | "The current Play version is %PLAY_VERSION%" 105 | ) must_== "

The current Play version is 2.1.3

" 106 | } 107 | "work in verbatim blocks" in { 108 | oldRenderer.render(""" 109 | | Here is some code: 110 | | 111 | | addSbtPlugin("org.playframework" % "sbt-plugin" % "%PLAY_VERSION%") 112 | | 113 | """.stripMargin) must contain("% "2.1.3")") 114 | } 115 | "work in code blocks" in { 116 | oldRenderer.render("Play `%PLAY_VERSION%` is cool.") must_== "

Play 2.1.3 is cool.

" 117 | } 118 | } 119 | 120 | "play table of contents support" should { 121 | "render a table of contents" in { 122 | renderer.renderPage("Home") must beSome[RenderedPage].like { case page => 123 | page.html must contain("

Sub Documentation

") 124 | page.html must contain("Foo Page") 125 | page.html must contain("Sub Section") 126 | page.html must contain("Sub Foo Page 1") 127 | } 128 | } 129 | } 130 | 131 | "header link rendering" should { 132 | "render header links" in { 133 | renderer.render("# Hello World") must_== 134 | """

§Hello World

""" 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/main/scala/play/doc/PageIndex.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | import org.apache.commons.io.IOUtils 8 | 9 | /** 10 | * A table of contents node 11 | */ 12 | sealed trait TocTree { 13 | 14 | /** 15 | * The page that this node should point to 16 | */ 17 | def page: String 18 | 19 | /** 20 | * The title of this node 21 | */ 22 | def title: String 23 | } 24 | 25 | /** 26 | * A table of contents 27 | * 28 | * @param title 29 | * The title of this table of contents 30 | * @param nodes 31 | * The nodes in the table of contents 32 | * @param descend 33 | * Whether a table of contents should descend into this table of contents 34 | */ 35 | case class Toc(name: String, title: String, nodes: List[(String, TocTree)], descend: Boolean = true) extends TocTree { 36 | require(nodes.nonEmpty) 37 | 38 | def page = nodes.head._2.page 39 | } 40 | 41 | /** 42 | * A page (leaf node) pointed to by the table of contents 43 | * 44 | * @param page 45 | * The page 46 | * @param title 47 | * The title of the page 48 | * @param next 49 | * Explicitly provided next links. If None, then the index structure are used to generate the next links, otherwise 50 | * these links should be used. Note that `Some(Nil)` is distinct from `None`, in that `Some(Nil)` means there should 51 | * be no next links, whereas `None` means let the index decide what the next link should be. 52 | */ 53 | case class TocPage(page: String, title: String, next: Option[List[String]]) extends TocTree 54 | 55 | /** 56 | * The page index 57 | * 58 | * @param toc 59 | * The table of contents 60 | */ 61 | class PageIndex(val toc: Toc, path: Option[String] = None) { 62 | private val byPage: Map[String, Page] = { 63 | // First, create a by name index 64 | def indexByName(node: TocTree): List[(String, TocTree)] = 65 | node match { 66 | case Toc(name, _, nodes, _) => 67 | (name -> node) :: nodes.map(_._2).flatMap(indexByName) 68 | case TocPage(name, _, _) => 69 | List(name -> node) 70 | } 71 | val byNameMap = indexByName(toc).toMap 72 | 73 | def indexPages(path: Option[String], nav: List[Toc], toc: Toc): List[Page] = { 74 | toc.nodes.flatMap { 75 | case (_, TocPage(page, title, explicitNext)) => 76 | val nextLinks = explicitNext 77 | .map { links => links.collect(Function.unlift(byNameMap.get)) } 78 | .getOrElse { 79 | findNext(page, nav).toList 80 | } 81 | List(Page(page, path, title, nav, nextLinks)) 82 | case (pathPart, tocPart: Toc) => 83 | indexPages( 84 | path.map(_ + "/" + pathPart).orElse(Some(pathPart)), 85 | tocPart :: nav, 86 | tocPart 87 | ) 88 | } 89 | } 90 | 91 | indexPages(path, List(toc), toc).map(p => p.page -> p).toMap 92 | } 93 | 94 | private def findNext(name: String, nav: List[Toc]): Option[TocTree] = { 95 | nav match { 96 | case Nil => None 97 | case toc :: rest => 98 | toc.nodes.view 99 | .dropWhile(_._1 != name) 100 | .drop(1) 101 | .headOption 102 | .map(_._2) 103 | .orElse( 104 | findNext(toc.name, rest) 105 | ) 106 | } 107 | } 108 | 109 | /** 110 | * Get the page for the given page name 111 | */ 112 | def get(page: String): Option[Page] = byPage.get(page) 113 | } 114 | 115 | /** 116 | * A page 117 | * 118 | * @param page 119 | * The page name 120 | * @param path 121 | * The path to the page 122 | * @param title 123 | * The title of the page 124 | * @param nav 125 | * The navigation associated with the page, this is a list of all the table of contents nodes, starting from the one 126 | * that this page is in, all the way up the tree to the root node 127 | * @param nextLinks 128 | * A list of next links 129 | */ 130 | case class Page(page: String, path: Option[String], title: String, nav: List[Toc], nextLinks: List[TocTree]) { 131 | def fullPath = path.fold(page)(_ + "/" + page) 132 | 133 | def next: Option[TocTree] = nextLinks.headOption 134 | } 135 | 136 | object PageIndex { 137 | def parseFrom(repo: FileRepository, home: String, path: Option[String] = None): Option[PageIndex] = { 138 | parseToc(repo, path, "", home) match { 139 | case toc: Toc => Some(new PageIndex(toc, path)) 140 | case _ => None 141 | } 142 | } 143 | 144 | private def parseToc( 145 | repo: FileRepository, 146 | path: Option[String], 147 | page: String, 148 | title: String, 149 | descend: Boolean = true, 150 | next: Option[List[String]] = None 151 | ): TocTree = { 152 | repo 153 | .loadFile(path.fold("index.toc")(_ + "/index.toc"))(IOUtils.toString(_, "utf-8")) 154 | .fold[TocTree]( 155 | TocPage(page, title, next) 156 | ) { content => 157 | // https://github.com/scala/bug/issues/11125#issuecomment-423375868 158 | val lines = augmentString(content).linesIterator.toList.map(_.trim).filter(_.nonEmpty) 159 | // Remaining lines are the entries of the contents 160 | val tocNodes = lines.map { entry => 161 | val linkAndTitle :: params = entry.split(";").toList 162 | val (link, title) = { 163 | linkAndTitle.split(":", 2) match { 164 | case Array(p) => p -> p 165 | case Array(p, t) => p -> t 166 | } 167 | } 168 | val parsedParams = params.map { param => 169 | param.split("=", 2) match { 170 | case Array(k) => k -> k 171 | case Array(k, v) => k -> v 172 | } 173 | }.toMap 174 | 175 | val next = parsedParams.get("next").map { n => n.split(",").toList } 176 | 177 | val (relPath, descend) = if (link.startsWith("!")) { 178 | link.drop(1) -> false 179 | } else { 180 | link -> true 181 | } 182 | 183 | relPath -> parseToc(repo, path.map(_ + "/" + relPath).orElse(Some(relPath)), relPath, title, descend, next) 184 | } 185 | Toc(page, title, tocNodes, descend) 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/main/scala/play/doc/PlayDoc.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. 3 | */ 4 | 5 | package play.doc 6 | 7 | import java.io.InputStream 8 | import java.io.File 9 | import java.util.Collections 10 | import java.util.Arrays 11 | import org.pegdown.* 12 | import org.pegdown.plugins.ToHtmlSerializerPlugin 13 | import org.pegdown.plugins.PegDownPlugins 14 | import org.pegdown.ast.* 15 | import org.apache.commons.io.IOUtils 16 | import scala.collection.JavaConverters.* 17 | 18 | /** 19 | * A rendered page 20 | * 21 | * @param html 22 | * The HTML for the page 23 | * @param sidebarHtml 24 | * The HTML for the sidebar 25 | * @param path 26 | * The path that the page was found at 27 | * @param breadcrumbsHtml 28 | * The HTML for the breadcrumbs. 29 | */ 30 | case class RenderedPage(html: String, sidebarHtml: Option[String], path: String, breadcrumbsHtml: Option[String]) 31 | 32 | /** 33 | * Play documentation support 34 | * 35 | * @param markdownRepository 36 | * Repository for finding markdown files 37 | * @param codeRepository 38 | * Repository for finding code samples 39 | * @param resources 40 | * The resources path 41 | * @param playVersion 42 | * The version of Play we are rendering docs for. 43 | * @param pageIndex 44 | * An optional page index. If None, will use the old approach of searching up the heirarchy for sidebar pages, 45 | * otherwise will use the page index to render the sidebar. 46 | * @param templates 47 | * The templates to render snippets. 48 | * @param pageExtension 49 | * The extension to add to rendered pages - used for rendering links. 50 | */ 51 | class PlayDoc( 52 | markdownRepository: FileRepository, 53 | codeRepository: FileRepository, 54 | resources: String, 55 | playVersion: String, 56 | val pageIndex: Option[PageIndex], 57 | templates: PlayDocTemplates, 58 | pageExtension: Option[String] 59 | ) { 60 | 61 | val PlayVersionVariableName = "%PLAY_VERSION%" 62 | 63 | /** 64 | * Render some markdown. 65 | */ 66 | def render(markdown: String, relativePath: Option[File] = None): String = { 67 | withRenderer(relativePath.map(_.getPath), None)(_(markdown)) 68 | } 69 | 70 | /** 71 | * Render a Play documentation page. 72 | * 73 | * @param pageName 74 | * The page to render, without path or markdown extension. 75 | * @return 76 | * If found a tuple of the rendered page and the rendered sidebar, if the sidebar was found. 77 | */ 78 | def renderPage(pageName: String): Option[RenderedPage] = { 79 | pageIndex.fold { 80 | renderOldPage(pageName) 81 | } { index => 82 | index.get(pageName).flatMap { page => 83 | withRenderer(page.path, page.nav.headOption) { renderer => 84 | val pagePath = page.fullPath + ".md" 85 | val renderedPage = markdownRepository.loadFile(pagePath)(inputStreamToString).map(renderer) 86 | 87 | renderedPage.map { html => 88 | val withNext = html + templates.nextLinks(page.nextLinks) 89 | RenderedPage(withNext, Some(templates.sidebar(page.nav)), pagePath, Some(templates.breadcrumbs(page.nav))) 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * Render all the pages of the documentation 98 | * 99 | * @param singlePage 100 | * Whether the pages are being rendered to be formatted on a single page 101 | */ 102 | def renderAllPages(singlePage: Boolean): List[(String, String)] = { 103 | pageIndex match { 104 | case Some(idx) => 105 | def collectPagesInOrder(node: TocTree): List[String] = { 106 | node match { 107 | case TocPage(page, _, _) => List(page) 108 | case toc: Toc => toc.nodes.flatMap(n => collectPagesInOrder(n._2)) 109 | } 110 | } 111 | val pages = collectPagesInOrder(idx.toc) 112 | pages.flatMap { pageName => 113 | idx 114 | .get(pageName) 115 | .flatMap { page => 116 | withRenderer(page.path, page.nav.headOption, singlePage = singlePage) { renderer => 117 | val pagePath = page.fullPath + ".md" 118 | markdownRepository.loadFile(pagePath)(inputStreamToString).map(renderer) 119 | } 120 | } 121 | .map { pageName -> _ } 122 | } 123 | case None => 124 | throw new IllegalStateException("Can only render all pages if there's a page index") 125 | } 126 | } 127 | 128 | /** 129 | * Render a Play documentation page. 130 | * 131 | * @param page 132 | * The page to render, without path or markdown extension. 133 | * @return 134 | * If found a tuple of the rendered page and the rendered sidebar, if the sidebar was found. 135 | */ 136 | private def renderOldPage(page: String): Option[RenderedPage] = { 137 | // Find the markdown file 138 | markdownRepository.findFileWithName(page + ".md").flatMap { pagePath => 139 | val file = new File(pagePath) 140 | // Work out the relative path for the file 141 | val relativePath = Option(file.getParentFile).map(_.getPath) 142 | 143 | def render(path: String, headerIds: Boolean = true): Option[String] = { 144 | withRenderer(relativePath, None, headerIds = headerIds) { renderer => 145 | markdownRepository.loadFile(path)(inputStreamToString).map(renderer) 146 | } 147 | } 148 | 149 | // Recursively search for Sidebar 150 | def findSideBar(file: Option[File]): Option[String] = 151 | file match { 152 | case None => None 153 | case Some(parent) => 154 | val sidebar = render(parent.getPath + "/_Sidebar.md", headerIds = false) 155 | sidebar.orElse(findSideBar(Option(parent.getParentFile))) 156 | } 157 | 158 | def findBreadcrumbs(file: Option[File]): Option[String] = 159 | file match { 160 | case None => None 161 | case Some(parent) => 162 | val breadcrumbs = render(parent.getPath + "/_Breadcrumbs.md", headerIds = false) 163 | breadcrumbs.orElse(findBreadcrumbs(Option(parent.getParentFile))) 164 | } 165 | 166 | // Render both the markdown and the sidebar 167 | render(pagePath).map { markdown => 168 | RenderedPage( 169 | markdown, 170 | findSideBar(Option(file.getParentFile)), 171 | pagePath, 172 | findBreadcrumbs(Option(file.getParentFile)) 173 | ) 174 | } 175 | } 176 | } 177 | 178 | private def addExtension(href: String) = { 179 | pageExtension match { 180 | case Some(extension) => 181 | // Need to add before the fragment 182 | if (href.contains("#")) { 183 | val parts = href.split('#') 184 | s"${parts.head}.$extension#${parts.tail.mkString("#")}" 185 | } else { 186 | s"$href.$extension" 187 | } 188 | case None => href 189 | } 190 | } 191 | 192 | private def withRenderer[T]( 193 | relativePath: Option[String], 194 | toc: Option[Toc], 195 | headerIds: Boolean = true, 196 | singlePage: Boolean = false 197 | )(block: (String => String) => T): T = { 198 | // Link renderer 199 | val link: (String => (String, String)) = { 200 | case link if link.contains("|") => 201 | val parts = link.split('|') 202 | val href = if (singlePage) "#" + parts.tail.head else addExtension(parts.tail.head) 203 | (href, parts.head) 204 | case image if Seq("png", "svg").exists(suffix => image.endsWith("." + suffix)) => 205 | val link = image match { 206 | case full if full.startsWith("http://") => full 207 | case absolute if absolute.startsWith("/") => resources + absolute 208 | case relative => resources + "/" + relativePath.map(_ + "/").getOrElse("") + relative 209 | } 210 | (link, """""") 211 | case link => 212 | if (singlePage) ("#" + link, link) else (addExtension(link), link) 213 | } 214 | 215 | val links = new LinkRenderer { 216 | override def render(node: WikiLinkNode) = { 217 | val (href, text) = link(node.getText) 218 | new LinkRenderer.Rendering(href, text) 219 | } 220 | } 221 | 222 | // Markdown parser 223 | val processor = new PegDownProcessor( 224 | Extensions.ALL & ~Extensions.ANCHORLINKS, 225 | PegDownPlugins 226 | .builder() 227 | .withPlugin(classOf[CodeReferenceParser]) 228 | .withPlugin(classOf[VariableParser], PlayVersionVariableName) 229 | .withPlugin(classOf[TocParser]) 230 | .build 231 | ) 232 | 233 | // ToHtmlSerializer's are stateful and so not reusable 234 | def htmlSerializer = 235 | new ToHtmlSerializer( 236 | links, 237 | Collections.singletonMap(VerbatimSerializer.DEFAULT, new VerbatimSerializerWrapper(PrettifyVerbatimSerializer)), 238 | Arrays.asList[ToHtmlSerializerPlugin]( 239 | new CodeReferenceSerializer(relativePath.map(_ + "/").getOrElse("")), 240 | new VariableSerializer(Map(PlayVersionVariableName -> FastEncoder.encode(playVersion))), 241 | new TocSerializer(toc) 242 | ) 243 | ) { 244 | var headingsSeen = Map.empty[String, Int] 245 | def headingToAnchor(heading: String) = { 246 | val anchor = FastEncoder.encode(heading.replace(' ', '-')) 247 | headingsSeen 248 | .get(anchor) 249 | .fold { 250 | headingsSeen += anchor -> 1 251 | anchor 252 | } { seen => 253 | headingsSeen += anchor -> (seen + 1) 254 | anchor + seen 255 | } 256 | } 257 | 258 | override def visit(node: CodeNode) = { 259 | super.visit(new CodeNode(node.getText.replace(PlayVersionVariableName, playVersion))) 260 | } 261 | 262 | override def visit(node: HeaderNode) = { 263 | // Put an id on header nodes 264 | printer 265 | .print(" Seq(t.getText) 275 | case other => collectTextNodes(other) 276 | } 277 | } 278 | val title = collectTextNodes(node).mkString 279 | val anchorId = headingToAnchor(title) 280 | 281 | printer 282 | .print(anchorId) 283 | .print("\"") 284 | 285 | printer.print(">") 286 | 287 | // Add section marker 288 | printer 289 | .print("") 293 | .print("§") 294 | .print("") 295 | } else { 296 | printer.print(">") 297 | } 298 | 299 | visitChildren(node) 300 | printer.print("") 301 | } 302 | } 303 | 304 | def render(markdown: String): String = { 305 | val astRoot = processor.parseMarkdown(markdown.toCharArray) 306 | htmlSerializer.toHtml(astRoot) 307 | } 308 | 309 | block(render) 310 | } 311 | 312 | // Directives to insert code, skip code and replace code 313 | private val Insert = """.*###insert: (.*?)(?:###.*)?""".r 314 | private val SkipN = """.*###skip:\s*(\d+).*""".r 315 | private val Skip = """.*###skip.*""".r 316 | private val ReplaceNext = """.*###replace: (.*?)(?:###.*)?""".r 317 | 318 | private class CodeReferenceSerializer(pagePath: String) extends ToHtmlSerializerPlugin { 319 | // Most files will be accessed multiple times from the same markdown file, no point in opening them many times 320 | // so memoize them. This cache is only per file rendered, so does not need to be thread safe. 321 | val repo = Memoize[String, Option[Seq[String]]] { path => 322 | codeRepository.loadFile(path) { is => IOUtils.readLines(is, "utf-8").asScala.toSeq } 323 | } 324 | 325 | def visit(node: Node, visitor: Visitor, printer: Printer) = 326 | node match { 327 | case code: CodeReferenceNode => { 328 | // Label is after the #, or if no #, then is the link label 329 | val (source, label) = code.getSource.split("#", 2) match { 330 | case Array(source, label) => (source, label) 331 | case Array(source) => (source, code.getLabel) 332 | } 333 | 334 | // The file is either relative to current page or absolute, under the root 335 | val sourceFile = if (source.startsWith("/")) { 336 | repo(source.drop(1)) 337 | } else { 338 | repo(pagePath + source) 339 | } 340 | 341 | val segment = if (label.nonEmpty) { 342 | val labelPattern = ("""\s*#\Q""" + label + """\E(\s|\z)""").r 343 | sourceFile.flatMap { sourceCode => 344 | val notLabel = (s: String) => labelPattern.findFirstIn(s).isEmpty 345 | val segment = sourceCode.dropWhile(notLabel).drop(1).takeWhile(notLabel) 346 | if (segment.isEmpty) { 347 | None 348 | } else { 349 | Some(segment) 350 | } 351 | } 352 | } else { 353 | sourceFile 354 | } 355 | 356 | segment 357 | .map { segment => 358 | // Calculate the indent, which is equal to the smallest indent of any line, excluding lines that only consist 359 | // of space characters 360 | val indent = segment 361 | .map { line => if (!line.exists(_ != ' ')) None else Some(line.indexWhere(_ != ' ')) } 362 | .reduce((i1, i2) => 363 | (i1, i2) match { 364 | case (None, None) => None 365 | case (i, None) => i 366 | case (None, i) => i 367 | case (Some(i1), Some(i2)) => Some(math.min(i1, i2)) 368 | } 369 | ) 370 | .getOrElse(0) 371 | 372 | // Process directives in segment 373 | case class State(buffer: StringBuilder = new StringBuilder, skip: Option[Int] = None) { 374 | def dropIndentAndAppendLine(s: String): State = { 375 | buffer.append(s.drop(indent)).append("\n") 376 | this 377 | } 378 | def appendLine(s: String): State = { 379 | buffer.append(s).append("\n") 380 | this 381 | } 382 | } 383 | val compiledSegment = segment 384 | .foldLeft(State()) { (state, line) => 385 | state.skip match { 386 | case Some(n) if n > 1 => state.copy(skip = Some(n - 1)) 387 | case Some(n) => state.copy(skip = None) 388 | case None => 389 | line match { 390 | case Insert(code) => state.appendLine(code) 391 | case SkipN(n) => state.copy(skip = Some(n.toInt)) 392 | case Skip() => state 393 | case ReplaceNext(code) => state.appendLine(code).copy(skip = Some(1)) 394 | case _ => state.dropIndentAndAppendLine(line) 395 | } 396 | } 397 | } 398 | .buffer /* Drop last newline */ 399 | .dropRight(1) 400 | .toString() 401 | 402 | // Guess the type of the file 403 | val fileType = source.split("\\.") match { 404 | case withExtension if withExtension.length > 1 => Some(withExtension.last) 405 | case _ => None 406 | } 407 | 408 | // And visit it 409 | fileType 410 | .map(t => new VerbatimNode(compiledSegment, t)) 411 | .getOrElse(new VerbatimNode(compiledSegment)) 412 | .accept(visitor) 413 | 414 | true 415 | } 416 | .getOrElse { 417 | printer.print("Unable to find label " + label + " in source file " + source) 418 | true 419 | } 420 | } 421 | case _ => false 422 | } 423 | } 424 | 425 | private class VariableSerializer(variables: Map[String, String]) extends ToHtmlSerializerPlugin { 426 | def visit(node: Node, visitor: Visitor, printer: Printer) = 427 | node match { 428 | case variable: VariableNode => { 429 | new TextNode(variables.get(variable.getName).getOrElse("Unknown variable: " + variable.getName)) 430 | .accept(visitor) 431 | true 432 | } 433 | case _ => false 434 | } 435 | } 436 | 437 | private class VerbatimSerializerWrapper(wrapped: VerbatimSerializer) extends VerbatimSerializer { 438 | def serialize(node: VerbatimNode, printer: Printer): Unit = { 439 | val text = node.getText.replace(PlayVersionVariableName, playVersion) 440 | wrapped.serialize(new VerbatimNode(text, node.getType), printer) 441 | } 442 | } 443 | 444 | private class TocSerializer(maybeToc: Option[Toc]) extends ToHtmlSerializerPlugin { 445 | def visit(node: Node, visitor: Visitor, printer: Printer) = 446 | node match { 447 | case tocNode: TocNode => 448 | maybeToc.fold(false) { t => 449 | printer.print(templates.toc(t)) 450 | true 451 | } 452 | case _ => false 453 | } 454 | } 455 | 456 | private val inputStreamToString: InputStream => String = IOUtils.toString(_, "utf-8") 457 | } 458 | 459 | private class Memoize[-T, +R](f: T => R) extends (T => R) { 460 | import scala.collection.mutable 461 | private[this] val vals = mutable.Map.empty[T, R] 462 | 463 | def apply(x: T): R = { 464 | if (vals.contains(x)) { 465 | vals(x) 466 | } else { 467 | val y = f(x) 468 | vals + ((x, y)) 469 | y 470 | } 471 | } 472 | } 473 | 474 | private object Memoize { 475 | def apply[T, R](f: T => R) = new Memoize(f) 476 | } 477 | --------------------------------------------------------------------------------