├── .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 | -
8 | @node._2.title
9 | @{node match {
10 | case (_, toc: Toc) if toc.descend && toc.nodes.size > 1 => renderToc(toc)
11 | case _ => ""
12 | }}
}
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 |
14 | @for((header, index) <- hierarchy.reverse.zipWithIndex) {
15 | @defining(index + 1) { count =>
16 | @if((index <= hierarchy.size)) {
17 | -
18 | @header.title
19 |
20 |
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/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 | [](https://twitter.com/playframework)
6 | [](https://discord.gg/g5s2vtZ4Fa)
7 | [](https://github.com/playframework/playframework/discussions)
8 | [](https://stackoverflow.com/tags/playframework)
9 | [](https://www.youtube.com/channel/UCRp6QDm5SDjbIuisUpxV9cg)
10 | [](https://www.twitch.tv/playframework)
11 | [](https://opencollective.com/playframework)
12 |
13 | [](https://github.com/playframework/play-doc/actions/workflows/build-test.yml)
14 | [](https://mvnrepository.com/artifact/org.playframework/play-doc_2.13)
15 | [](https://github.com/playframework/play-doc)
16 | [](https://scala-steward.org)
17 | [](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 |
--------------------------------------------------------------------------------