8 minutes to read
generateSite
About This Task
When you have only one document, the output of generateHTML
might meet your requirements.
But as your documentation grows, and you have multiple documents, you will need a microsite which bundles all the information.
The generateSite
task uses jBake to create a static site with a landing page, a blog and search.
Pages
The microsite is page-oriented, not document-oriented. If you have already organized your documents by chapter, use them as pages to create a great user experience. The arc42-template sources are a good example.
To include a page in the microsite, add a metadata header to it.
:jbake-menu: arc42
:jbake-title: Solution Strategy
:jbake-order: 4
:jbake-type: page_toc
:jbake-status: published
:filename: 015_tasks/03_task_generateSite.adoc
:toc:
[[section-solution-strategy]]
=== Solution Strategy
Here is an overview of each element:
jbake-menu
The top-level menu’s code for this page.
Defaults to the top-level folder name (without the order prefix) of the .adoc file within the docDir
.
Example: if the top-level folder name is 10_news
it will default to the value news
.
For each code, the display text and the order in the top-level menu can be configured.
jbake-title
The title to be displayed in the drop-down top-level menu. Defaults to the first headline of the file.
jbake-order
Applies a sort order to drop-down entries.
Defaults to a prefixed file number, such as 04_filename.adoc
or to the prefixed number of the second-level folder name.
When nothing is defined the default value is -1
or -987654321
for index
pages.
jbake-type
The page type.
Controls which template is used to render the page.
You will mostly use page
for a full-width page or page_toc
for a page with a table of contents (toc) rendered on the left.
Defaults to page_toc
.
jbake-status
Either draft
or published
.
Only published
pages will be rendered.
Defaults to published
for files with a jbake-order
and draft
for files without jbake-order
or files prefixed with _
.
filename
Required for edit and feedback links (coming soon). Defaults to the filename :-).
ifndef
Fixes the imagesdir according to the nesting level of your docs folder.
Defaults to the main docDir/images
.
toc
For :jbake-type: page_toc
, you need this line to generate the toc.
Note
|
Start your pages with a == level headline.
You can fix the level offset when you include the page in a larger document with include::chapter.adoc[leveloffset=+1] .
|
Configuration
The configuration follows the convention-over-configuration approach. If you follow the conventions, you don’t have to configure anything. But if you want to, you can override the convention behaviour with a configuration.
Menu
Navigation elements
The navigation is organized with following elements:
-
A top level menu.
-
For each item of this top level menu, a section sidebar on the left.
The location of a page in the top-level menu and in the section sidebar depends on:
-
Its location in the folder structure
-
Page attributes
-
Site configurations
Example:
src/docs/
├── 10_foo
│ ├── 10_first.adoc
│ └── 20_second.adoc
└── 20_bar
├── 10_lorem.adoc
├── 20_ipsum
│ ├── 10_topic-A.adoc
│ └── 20_topic-B.adoc
└── 30_delis
├── 10_one.adoc
├── 20_two.adoc
└── index.adoc
The top level folders (10_foo
and 20_bar
) are used to determine to which menu-code the page belongs (foo
and bar
, unless overridden by the :jbake-menu:
inside each page).
In the section sidebar, the navigation tree is determined by the folder structure.
Folders are nodes in the sidebar tree.
Each node can contain pages (leafs) or other folders (child nodes).
The order is controlled by the prefix of the file or folder name (unless overridden by the :jbake-order:
inside each page).
When an index page is present (like 20_bar/30_delis/index.adoc
in the example) then the navigation tree node corresponds to this index page (you can click on it and the title is taken from the page).
When this index.adoc
does not declare a specific order with :jbake-order:
then the order of the parent folder (for the example: 30
because the folder is named 30_delis
).
When the index page is absent (like there is no 20_bar/20_ipsum/index.adoc
in the example) then the name of the folder is used to create the node, and you can not click on the node because no pages is associated with this node.
You can still define the order with the name (for the example 20
because the folder is named 20_ipsum
).
When there is no sub-folder, only a flat list of pages is created.
Note
|
When an index.adoc page is defined inside the top level folder (like: 10_foo/index.adoc or 20_bar/index.adoc ) then the page will be listed in the section navigation tree in the sidebar as any other regular page. By default, it will be the first element of the tree, unless the value is overridden by a :jbake-order: attribute.
|
Configuring the top level menu
The :jbake-menu:
is only the code for the menu entry to be created.
You can map these codes to menu entries through the configuration (microsite
-Section) in the following way:
menu = [code1: 'Some Title 1', code2: 'Other Title 2', code3: '-']
When no mapping is defined in the configuration file, the code is used as title.
The menu configuration is also impacting the display order.
If you have four files, located in following folder structure:
src/docs
├── code1
│ ├── demo1.adoc
│ └── demo2.adoc
└── code3
├── demo3.adoc
└── _demo4.adoc
Where demo1.adoc
and demo3.adoc
contain no :jbake-menu:
header, demo2.adoc
contains :jbake-menu: code2
, then:
-
demo1.adoc
will have a menu-code ofcode1
because it is located in the foldercode1
. This code is translated through the configuration to the menu namedSome Title 1
. -
demo2.adoc
is in the same folder, but the:jbake-menu:
attribute has a higher precedence which results in menu-codecode2
. This code is translated through the configuration to the menu namedOther Title 2
. -
demo3.adoc
will have a menu-codecode3
because it is located in the foldercode3
. This code is translated through the configuration to the special menu-
which will not be displayed. This is an easy way to hide a menu in the rendered microsite. -
_demo4.adoc
starts with an underscore_
and thus will be handled asdraft
(:jbake-status: draft
). It will not be rendered as part of any menu, but it will be available in the microsite as "hidden"_demo4-draft.html
. Feel free to remove these draft renderings before you publish your microsite.
Links
In the column on the right, links are driven by the values defined in docToolchainConfig.groovy
.
-
"Improve this doc": displayed when
gitRepoUrl
is set. -
"Create an issue": displayed when
issueUrl
is set.
Configuring the JBake plugin
Behind the scene the generateSite
task is relying on Jbake.
In the docToolchainConfig.groovy
it is possible to amend the configuration of the jbake gradle plugin:
-
Add additional asciidoctorj plugins (add dependencies to the
jbake
configuration) -
Add additional asciidoctor attributes
//customization of the Jbake gradle plugin used by the generateSite task
jbake.with {
// possibility to configure additional asciidoctorj plugins used by jbake
plugins = [ ]
// possibility to configure additional asciidoctor attributes passed to the jbake task
asciidoctorAttributes = [ ]
}
The plugins are retrieved from a repository (by default maven-central) configured with project property depsMavenRepository
.
When a repository requiring credentials is used the properties depsMavenUsername
and depsMavenPassword
can be set as well.
Templates and Style
The jBake templates and CSS are hidden for convenience.
The basic template uses Twitter Bootstrap 5 as its CSS framework.
Use the copyThemes
task to copy all hidden jBake resources to your project.
You can then remove the resources you don’t need, and change those you want to change.
Note
|
copyThemes overwrites existing files, but because your code is safely managed using version control, this shouldn’t be a problem.
|
Landing Page
Place an index.gsp
page as your landing page in src/site/templates
.
This landing page is plain HTML5 styled with Twitter Bootstrap.
The page header and footer are added by docToolchain.
An example can be found at copyThemes
or on GitHub.
Blog
The microsite also contains a simple but powerful blog. Use it to inform your team about changes, as well as architecture decision records (ADRs).
To create a new blog post, create a new file in src/docs/blog/<year>/<post-name>.adoc
with the following template:
:jbake-title: <title-of your post>
:jbake-date: <date formatted as 2021-02-28>
:jbake-type: post
:jbake-tags: <blog, asciidoc>
:jbake-status: published
:imagesdir: ../../images
= {jbake-title}
{jbake-author}
{jbake-date}
<insert your text here>
Search
The microsite does not have its own local search. But it does have a search input field which can be used to link to another search engine.
CI/CD
When running in an automated build, set the environment variable DTC_HEADLESS
to true
or 1
. This stops docToolchain from asking to install the configured theme, and it will simply assume that you do want to install it.
You can also avoid the theme downloading with every build by copying the themes folder from $HOME/.doctoolchain/themes
to the corresponding folder in your build container.
Further Reading and Resources
Read about the previewSite
task here.
Source
Show source code of scripts/generateSite.gradle
or go directly to GitHub · docToolchain/scripts/generateSite.gradle.
import groovy.util.*
import static groovy.io.FileType.*
buildscript {
repositories {
maven {
credentials {
username mavenUsername
password mavenPassword
}
url mavenRepository
}
}
dependencies {
classpath libs.asciidoctorj.diagram
}
}
repositories {
maven {
credentials {
username depsMavenUsername
password depsMavenPassword
}
url depsMavenRepository
}
}
dependencies {
jbake libs.asciidoctorj.diagram
jbake libs.pebble
config.jbake.plugins.each { plugin ->
jbake plugin
}
}
apply plugin: 'org.jbake.site'
def color = { color, text ->
def colors = [black: 30, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37]
return new String((char) 27) + "[${colors[color]}m${text}" + new String((char) 27) + "[0m"
}
jbake {
version = '2.6.7'
srcDirName = "${targetDir}/microsite/tmp/site"
destDirName = "${targetDir}/microsite/output"
configuration['asciidoctor.option.requires'] = "asciidoctor-diagram"
config.microsite.each { key, value ->
configuration['site.'+key-'config.microsite.'] = value?:''
//println 'site.'+key-'config.microsite.' +" = "+ value
}
def micrositeContextPath = config.microsite.contextPath?:'/'
configuration['asciidoctor.attributes'] = [
"sourceDir=${targetDir}",
'source-highlighter=prettify@',
//'imagesDir=../images@',
"imagesoutDir=${targetDir}/microsite/output/images@",
"imagesDir=${micrositeContextPath.endsWith('/') ? micrositeContextPath : micrositeContextPath.concat('/')}images@",
"targetDir=${targetDir}",
"docDir=${docDir}",
"projectRootDir=${new File(docDir).canonicalPath}@",
]
if(config.jbake.asciidoctorAttributes) {
config.jbake.asciidoctorAttributes.each { entry ->
configuration['asciidoctor.attributes'] << entry
}
}
}
def prepareAndCopyTheme = {
//copy internal theme
println "copy internal theme ${new File(projectDir, 'src/site').canonicalPath}"
copy {
from('src/site')
into("${targetDir}/microsite/tmp/site")
}
//check if a remote pdfTheme is defined
def siteTheme = System.getenv('DTC_SITETHEME')?:""
def themeFolder = new File(projectDir, "../themes/" + siteTheme.md5())
try {
if (siteTheme) {
println "use siteTheme $siteTheme"
//check if it is already installed
if (!themeFolder.exists()) {
if (System.getenv('DTC_HEADLESS')) {
ant.yesno = "y"
} else {
println "${color 'green', """\nTheme '$siteTheme' is not installed yet. """}"
def input = ant.input(message: """
${color 'green', 'do you want me to download and install it to '}
${color 'green', ' ' + themeFolder.canonicalPath}
${color 'green', 'for you?'}\n""",
validargs: 'y,n', addproperty: 'yesno')
}
if (ant.yesno == "y") {
themeFolder.mkdirs()
download.run {
src siteTheme
dest new File(themeFolder, 'siteTheme.zip')
overwrite true
}
copy {
from zipTree(new File(themeFolder, 'siteTheme.zip'))
into themeFolder
}
delete {
delete new File(themeFolder, 'siteTheme.zip')
}
} else {
println "${color 'green', """\nI will continue without the theme for now... """}"
siteTheme = ""
}
}
//copy external theme
if (siteTheme) {
copy {
from(themeFolder) {}
into("${targetDir}/microsite/tmp/")
}
//check if the config has to be updated
// check if config still contains /** microsite **/
def configFile = new File(docDir, mainConfigFile)
def configFileText = configFile.text
if (configFileText.contains("/** start:microsite **/")) {
def configFragment = new File(targetDir,'/microsite/tmp/site/configFragment.groovy')
if (configFragment.exists()) {
println "${color 'green', """
It seems that this theme is used for the first time in this project.
Let's configure it!
If you are unsure, change these settings later in your config file
$configFile.canonicalPath
"""}"
def comment = ""
def conf = ""
def example = ""
def i = 0
configFragment.eachLine { line ->
if (line.trim()) {
if (line.startsWith("//")) {
conf += " " + line + "\n"
def tmp = line[2..-1].trim()
comment += color('green', tmp) + "\n"
if (tmp.toLowerCase().startsWith("example")) {
example = tmp.replaceAll("[^ ]* ", "")
}
} else {
//only prompt if there is something to prompt
if (line.contains("##")) {
def property = line.replaceAll("[ =].*", "")
if (!example) {
example = config.microsite[property]
}
comment = color('blue', "$property") + "\n" + comment
if (example) {
ant.input(message: comment,
addproperty: 'res' + i, defaultvalue: example)
} else {
ant.input(message: comment,
addproperty: 'res' + i)
}
(comment, example) = ["", ""]
line = line.replaceAll("##.+##", ant['res' + i])
conf += " " + line + "\n"
i++
} else {
conf += " " + line + "\n"
}
}
} else {
conf += "\n"
}
}
configFile.write(configFileText.replaceAll("(?sm)/[*][*] start:microsite [*][*]/.*/[*][*] end:microsite [*][*]/", "%%marker%%").replace("%%marker%%", conf))
println color('green', "config written\nopen ${targetDir}/microsite/output/index.html in your browser\nto see your microsite!")
}
//copy the dummy docs (blog, landing page) to the project repository
copy {
from(new File(themeFolder, 'site/doc')) {}
into(new File(docDir, inputPath))
}
}
}
}
} catch (Exception e) {
println color('red', e.message)
if (e.message.startsWith("Not Found")) {
themeFolder.deleteDir()
throw new GradleException("Couldn't find theme. Did you specify the right URL?\n"+e.message)
} else {
throw new GradleException(e.message)
}
}
//copy project theme
if (config.microsite.siteFolder) {
def projectTheme = new File(new File(docDir, inputPath), config.microsite.siteFolder)
println "copy project theme ${projectTheme.canonicalPath}"
copy {
from(projectTheme) {}
into("${targetDir}/microsite/tmp/site")
}
}
}
def convertAdditionalFormats = {
if (config.microsite.additionalConverters) {
File sourceFolder = new File(targetDir, '/microsite/tmp/site/doc')
sourceFolder.traverse(type: FILES) { file ->
def extension = '.' + file.name.split("[.]")[-1]
if (config.microsite.additionalConverters[extension]) {
def command = config.microsite.additionalConverters[extension].command
def type = config.microsite.additionalConverters[extension].type
def binding = new Binding([
file : file,
config: config
])
def shell = new GroovyShell(getClass().getClassLoader(), binding)
switch (type) {
case 'groovy':
shell.evaluate(command)
break
case 'groovyFile':
shell.evaluate(new File(docDir, command).text)
break
case 'bash':
if (command=='dtcw:rstToHtml.py') {
// this is an internal script
command = projectDir.canonicalPath+'/scripts/rstToHtml.py'
}
command = ['bash', '-c', command + ' "' + file + '"']
def process = command.execute([], new File(docDir))
process.waitFor()
if (process.exitValue()) {
def error = process.err.text
println """
can't convert '${file.canonicalPath-docDir-'/build/microsite/tmp/site/doc'}':
${error}
"""
throw new Exception("""
can't convert '${file.canonicalPath-docDir-'/build/microsite/tmp/site/doc'}':
${error}
""")
}
}
}
}
}
}
def parseAsciiDocAttribs = { origText, jbake ->
def parseAttribs = true
def text = ""
def beforeToc = ""
origText.eachLine { line ->
if (parseAttribs && line.startsWith(":jbake")) {
def parsedJbakeAttribute = (line - ":jbake-").split(": +", 2)
if(parsedJbakeAttribute.length != 2) {
logger.warn("jbake-attribute is not valid or Asciidoc conform: $line")
logger.warn("jbake-attribute $line will be ignored, trying to continue...")
} else {
jbake[parsedJbakeAttribute[0]] = parsedJbakeAttribute[1]
}
} else {
if (line.startsWith("[")) {
// stop parsing jBake-attribs when a [source] - block starts which might contain those attribs as example
parseAttribs = false
}
text += line+"\n"
//there are some attributes which have to be set before the toc
if (line.startsWith(":toc") ) {
beforeToc += line+"\n"
}
}
}
return [text, beforeToc]
}
def parseOtherAttribs = { origText, jbake ->
if (origText.contains('~~~~~~')) {
def parseAttribs = true
def text = ""
origText.eachLine { line ->
if (parseAttribs && line.contains("=")) {
line = (line - "jbake-").split("=", 2)
jbake[line[0]] = line[1]
} else {
if (line.startsWith("~~~~~~")) {
// stop parsing jBake-attribs when delimiter shows up
parseAttribs = false
} else {
text += line + "\n"
}
}
}
return text
} else {
return origText
}
}
def renderHeader = { fileName, jbake ->
def header = ''
if (fileName.toLowerCase() ==~ '^.*(html|md)$') {
jbake.each { key, value ->
if (key == 'order') {
header += "jbake-${key}=${(value ?: '1') as Integer}\n"
} else {
if (key in ['type', 'status']) {
header += "${key}=${value}\n"
} else {
header += "jbake-${key}=${value}\n"
}
}
}
header += "~~~~~~\n\n"
} else {
jbake.each { key, value ->
if (key == 'order') {
header += ":jbake-${key}: ${(value ?: '1') as Integer}\n"
} else {
header += ":jbake-${key}: ${value}\n"
}
}
}
return header
}
def fixMetaDataHeader = {
//fix MetaData-Header
File sourceFolder = new File(targetDir, '/microsite/tmp/site/doc')
logger.info("sourceFolder: " + sourceFolder.canonicalPath)
sourceFolder.traverse(type: FILES) { file ->
if (file.name.toLowerCase() ==~ '^.*(ad|adoc|asciidoc|html|md)$') {
if (file.name.startsWith("_") || file.name.startsWith(".")) {
//ignore
} else {
def origText = file.text
//parse jbake attributes
def text = ""
def jbake = [
status: "published",
order: -1,
type: 'page_toc'
]
if (file.name.toLowerCase() ==~ '^.*(md|html)$') {
// we don't have a toc for md or html
jbake.type = 'page'
}
def beforeToc = ""
if (file.name.toLowerCase() ==~ '^.*(ad|adoc|asciidoc)$') {
(text, beforeToc) = parseAsciiDocAttribs(origText, jbake)
} else {
text = parseOtherAttribs(origText, jbake)
}
def name = file.canonicalPath - (sourceFolder.canonicalPath+File.separator)
if (File.separator=='\\') {
name = name.split("\\\\")
} else {
name = name.split("/")
}
if (name.size()>1) {
if (!jbake.menu) {
jbake.menu = name[0]
if (jbake.menu ==~ /[0-9]+[-_].*/) {
jbake.menu = jbake.menu.split("[-_]", 2)[1]
}
}
def docname = name[-1]
if (docname ==~ /[0-9]+[-_].*/) {
jbake.order = docname.split("[-_]",2)[0]
docname = docname.split("[-_]",2)[1]
}
if (name.size() > 2) {
if ((jbake.order as Integer)==0) {
// let's take the order from the second level dir or file and not the file
def secondLevel = name[1]
if (secondLevel ==~ /[0-9]+[-_].*/) {
jbake.order = secondLevel.split("[-_]",2)[0]
}
} else {
if (((jbake.order?:'1') as Integer) > 0) {
//
} else {
jbake.status = "draft"
}
}
}
if (jbake.order==-1 && docname.startsWith('index')) {
jbake.order = -987654321 // special 'magic value' given to index pages.
jbake.status = "published"
}
// news blog
if (jbake.order==-1 && jbake.type=='post') {
jbake.order = 0
try {
jbake.order = Date.parse("yyyy-MM-dd", jbake.date).time / 100000
} catch ( Exception e) {
System.out.println "unparsable date ${jbake.date} in $name"
}
jbake.status = "published"
}
def leveloffset = 0
if (file.name.toLowerCase() ==~ '^.*(ad|adoc|asciidoc)$') {
text.eachLine { line ->
if (!jbake.title && line ==~ "^=+ .*") {
jbake.title = (line =~ "^=+ (.*)")[0][1]
def level = (line =~ "^(=+) .*")[0][1]
if (level == "=") {
leveloffset = 1
}
}
}
} else {
if (file.name.toLowerCase() ==~ '^.*(html)$') {
if (!jbake.title) {
text.eachLine { line ->
if (!jbake.title && line ==~ "^<h[1-9]>.*</h.*") {
jbake.title = (line =~ "^<h[1-9]>(.*)</h.*")[0][1]
}
}
}
} else {
// md
if (!jbake.title) {
text.eachLine { line ->
if (!jbake.title && line ==~ "^#+ .*") {
jbake.title = (line =~ "^#+ (.*)")[0][1]
}
}
}
}
}
if (!jbake.title) {
jbake.title = docname
}
if (leveloffset==1) {
//leveloffset needed
// we always start with "==" not with "="
// only used for adoc
text = text.replaceAll("(?ms)^(=+) ", '$1= ')
}
if (config.microsite.customConvention) {
def binding = new Binding([
file : file,
sourceFolder : sourceFolder,
config: config,
headers : jbake
])
def shell = new GroovyShell(getClass().getClassLoader(), binding)
shell.evaluate(config.microsite.customConvention)
System.out.println jbake
}
def header = renderHeader(file.name, jbake)
if (file.name.toLowerCase() ==~ '^.*(ad|adoc|asciidoc)$') {
file.write(header + "\nifndef::dtc-magic-toc[]\n:dtc-magic-toc:\n$beforeToc\n\n:toc: left\n\n++++\n<!-- endtoc -->\n++++\nendif::[]\n" + text, "utf-8")
} else {
file.write(header + "\n" + text, "utf-8")
}
}
}
}
}
}
task generateSite(
group: 'docToolchain',
description: 'generate a microsite using jBake.') {
doLast {
new File("${targetDir}/microsite/tmp").mkdirs()
println new File("${targetDir}/microsite/tmp/").canonicalPath
prepareAndCopyTheme()
//copy docs
copy {
from(new File(docDir, inputPath)) {}
into("${targetDir}/microsite/tmp/site/doc")
}
// if configured, convert restructuredText or anything else
convertAdditionalFormats()
// convention over configuration
fixMetaDataHeader()
}
}
task previewSite(
group: 'docToolchain',
dependsOn: [],
description: 'preview your Microsite',
) {
doLast {
println("previewSite command has been deprecated.")
println("To preview your site, open ${targetDir}/microsite/output/index.html in your browser.")
println("To read alternative ways to preview your site, please consult the documentation.")
}
}
previewSite.dependsOn(generateSite)
previewSite.mustRunAfter(bake)
task copyImages(type: Copy) {
config.imageDirs.each { imageDir ->
from(new File (new File(docDir, inputPath),imageDir)) {}
logger.info ('imageDir: '+imageDir)
into("${targetDir}/microsite/output/images")
}
config.resourceDirs.each { resource ->
from(new File(file(srcDir),resource.source))
logger.info ('resource: '+resource.source)
into("${targetDir}/microsite/output/" + resource.target)
}
}
bake.dependsOn copyImages
generateSite.finalizedBy bake
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.