A static, bilingual (DE/EN) website for lecture notes, generated with JBake. Each lecture consists of Markdown files that can be shown either as a document (continuous text) or as a slide deck. PlantUML diagrams and LaTeX formulas are typeset locally – without any external services.
Authoring content? How to write a lecture (Markdown, images, diagrams, formulas) is demonstrated by the lecture "Tutorial: Authoring a Lecture" included in the project, using live examples. This README describes the technical and configurational aspects of the system.
- Requirements
- Building and viewing
- A content repository of its own
- Project layout
- The build pipeline
- Configuration
- Location of the content folder (
content.dir) - Site-wide metadata (
content/meta.properties) - Lectures and the meta.properties cascade
- Visibility:
publish - Document-only lectures (
documentOnly) - Header controls (
showViewSwitch,showFullscreen,showHeaderToggle) - Internationalization (i18n)
- Interface state in the URL
- Images
- Site-specific styles (
content/style.css) - Automatic values (date, copyright)
- Enabling LaTeX/formulas (
math) - Maps (
mapblocks) - Smooth scrolling
- Syntax highlighting
- Location of the content folder (
- License
- JDK 17 or newer
- Maven 3.8+
All further dependencies (JBake, PlantUML, MathJax) are provided via Maven or bundled locally – no Graphviz and no internet connection are required at runtime.
mvn clean packageThe result is placed in target/website/. To view it, start a local web server
(relative paths do not work over file://):
cd target/website && python3 -m http.server 8080Then open http://localhost:8080.
Engine and content can live in separate repositories: this project brings pom.xml, src/,
templates/, assets/ and jbake.properties, the other one only its content/ folder. Two
ways to build such a repo, and they can be used side by side:
In CI (GitHub Actions). Copy content-repo.github-workflow.yml from this repository into
the content repo as .github/workflows/pages.yml and check the env: block – ENGINE_REPO
(where this engine lives), ENGINE_REF (pin it to a tag for reproducible builds) and
CONTENT_DIR. The workflow checks out both repositories, builds with -Dcontent.dir pointing
at the content repo and -Dsite.dir at a folder of its own, and publishes that as GitHub
Pages. Two things are set once in the content repo: Settings > Pages > Source: GitHub
Actions, and – only if this engine repository is private – a token with read access for the
second checkout step.
Note the difference to this project’s own .github/workflows/pages.yml: that one builds
this repository – engine plus the tutorial – and publishes it as the engine’s demo site.
In CI (GitLab). A content repo that lives on a GitLab instance uses
content-repo.gitlab-ci.yml the same way (copy it there as .gitlab-ci.yml). It clones this
engine from GitHub – ENGINE_URL, ENGINE_REF – so the code base stays in one place; the
runner only needs to reach github.com, and as long as the engine repository is public the clone
needs no credentials. For a private engine, put a token into a masked CI/CD variable and use it
in the clone URL, as the comment at the top of the file describes.
.gitlab-ci.yml in this repository is the leftover pipeline for building the engine itself on a
GitLab instance; on GitHub that job is done by .github/workflows/pages.yml.
Locally. A small pom.xml in the content repo can drive the engine, so that mvn package
works there as usual: an exec execution runs mvn -f <engine>/pom.xml package -Dcontent.dir=<here>/content -Dsite.dir=<here>/target/website. Keep the engine directory
configurable (-Dengine.dir=…) and clear target/website before the run, since JBake does
not wipe its output. The site of the Deutsche Borreliose-Gesellschaft is built that way.
Whichever route: everything the pages load must be committed. Assets added later – a
bundled library, marker images, a content/data/*.json – are new files, and git commit -a
does not pick those up. They are the usual reason why something works locally and stays empty
on Pages.
content/ source content (Markdown, images, configuration)
meta.properties site-wide metadata (lecturer, university, faculty …)
site_de/en.properties interface texts (menu, buttons …) per language
index.md, search.md landing page (unpublished here, see below) and search
images/ site-wide images (logo, favicon …)
data/ data sets for the browser (e.g. map pins, *.json)
common/images/ images shared by all languages of the lecture
de/ en/ one folder per language of the lecture
meta.properties lectureTitle per language
index.md entry topic (lowest navorder) – serves /de/ and /en/
*.md the further topics (slides)
images/ language-specific images (optional)
<lecture>/ further lectures keep a folder of their own:
meta.properties lecture metadata (icon, slug …)
common/images/
de/ en/ … same structure as above
templates/ FreeMarker templates (header, page, footer)
assets/ generic UI files (CSS, JS, UI icons)
js/tex-svg.js locally bundled MathJax (formula typesetting)
js/highlight.min.js locally bundled highlight.js (syntax highlighting)
css/highlight.css colour scheme for syntax highlighting
js/lunr.min.js full-text search
js/leaflet.js, css/leaflet.css locally bundled Leaflet (maps)
src/main/java/de/hshn/lectures/build/ the build tools (see below)
jbake.properties JBake configuration
pom.xml Maven build
.github/workflows/pages.yml pipeline of THIS repo (engine + tutorial → GitHub Pages)
content-repo.github-workflow.yml template for a content repo (copy it there as
.github/workflows/pages.yml)
content-repo.gitlab-ci.yml the same for a content repo on a GitLab instance
(clones this engine from GitHub)
.gitlab-ci.yml leftover: building the engine itself on GitLab
target/website/ generated website (build output)
Core principle: assets/ holds exclusively generic building blocks (CSS, JS, UI
icons). Everything content-related – texts, translations, images, the university logo
and the favicon – lives under content/ and is copied into place during the build.
The build runs in the Maven package phase. First the shade plugin produces an executable
JAR containing the build tools, then the exec plugin runs their entry point, and finally
JBake bakes the website. The tools (package de.hshn.lectures.build) run before JBake
and partly write directly into target/website/ – JBake does not wipe its output directory,
so these files survive the bake.
| Tool | Purpose |
|---|---|
PlantumlPreprocessor |
Entry point; renders ```plantuml blocks and *.puml files to SVGs under target/website/images/plantuml/ using the Graphviz-free Smetana layout. Unchanged diagrams are skipped via SHA-256. Then invokes MetaMerge, ImageCascade and IndexRedirects. |
MetaMerge |
Merges the meta.properties cascade into each .md's front matter and writes the result to target/staged-content/ (JBake's actual source folder). Evaluates publish, fills in a missing date, builds the i18n tables. |
ImageCascade |
Materializes images according to the cascade (see below) into target/website/. |
IndexRedirects |
Writes the index.html files for folder URLs – /<lecture>/<lang>/, /<lecture>/, and the site root when no start page is baked – so that they land on the lecture's entry topic instead of a "Not Found". Where one entry point serves several languages, the redirect keeps the reader's chosen language (meta refresh to the primary language without JavaScript). Nothing is written where the entry topic is itself called index.md: the real page already sits there. |
DataBundle |
Publishes content/data/*.json as data/*.js (window.MAPDATA), so that maps and other data-driven pages work without a runtime fetch (and therefore under file:// too). |
TextIO |
UTF-8-tolerant reading (with an ISO-8859-1 fallback), so that accented characters in configuration and content files never break the build. |
The location of the source content folder is centrally configurable via a Maven property in
pom.xml and is passed as an argument to the build tools:
<properties>
<content.dir>${project.basedir}/content</content.dir>
</properties>Override it without editing pom.xml:
mvn clean package -Dcontent.dir=/path/to/my/contentA second property decides where the finished site goes:
mvn clean package -Dcontent.dir=/path/to/my/content -Dsite.dir=/path/to/my/target/websitesite.dir defaults to target/website of this project. A content project that drives the
engine sets it to a directory of its own – otherwise its site would overwrite the one the
engine builds from its own content. Staging (target/staged-content) and the build tools’
scratch files deliberately stay in the engine’s target/, because JBake resolves its source
folder relative to the engine project.
The exec plugin passes three paths to the main method: the content dir, the Maven build
directory (${project.build.directory}, usually target) and the site dir.
These values are merged into every page and are available in the templates as
content.<key>. An empty *Url makes the corresponding name or logo render as plain text
without a hyperlink. All values are UTF-8 capable.
| Key | Meaning |
|---|---|
lecturerName, lecturerUrl, lecturerUrlEn |
lecturer's name in the footer + (language-dependent) link |
universityName, universityNameEn |
university name (footer) |
universityUrl, universityUrlEn |
target of the logo in the top left |
universityLogo, universityLogoEn |
logo file (in content/images/) per language |
facultyName, facultyNameEn |
faculty name; a ` |
facultyUrl, facultyUrlEn |
target of the faculty name |
footerOrg |
one organisation in the footer instead of the lecturer – faculty, university chain. Printed verbatim, so the closing full stop (“… e.V.”) belongs into the value. |
siteTitle |
what closes the browser tab’s title, after page and lecture name (default: universityName). A site named like its single lecture drops the duplicate lecture part. |
mapTiles, mapAttribution |
tile server and credit for all maps of the site (see Maps) |
External links automatically open in a new tab (target="_blank").
Recurring metadata is not repeated in every .md but stored in meta.properties files.
During the build, a page's front matter results from the cascade (increasing precedence):
content/meta.properties(site-wide)content/<lecture>/meta.properties(e.g.navgroup=docs,lecture=<slug>,lectureIcon=…)content/<lecture>/<lang>/meta.properties(lectureTitleper language)- the
.md's own front matter (wins)
The language is derived from the folder name (de/en) and does not need to be
maintained. A topic page therefore only needs title and navorder in its front matter.
A site that carries a single lecture can drop the <lecture> level and put de/, en/ and
common/ directly under content/ – the demo content in this repository is laid out that
way. The pages are then served from /de/… and /en/… instead of /<lecture>/de/…, and the
middle step of the cascade collapses into the site-wide file:
content/
meta.properties site-wide metadata + navgroup, lecture, lectureIcon
common/images/
de/ en/
meta.properties lectureTitle per language
index.md entry topic – serves /de/ and /en/ directly
*.md the further topics
Everything else stays as it is – the image cascade, the folder redirects, sidebar, search and
the [LECTURES] tiles all work the same. Four things are worth knowing:
navgroup,lectureandlectureIconnow sit in the site-widemeta.properties, so they reach every page. That is harmless forindex.mdandsearch.md(their ownnavgroupin the front matter wins) and for further lectures in their own folder (they overridelectureandlectureIcon), but keys meant for one lecture only are better placed inde/meta.propertiesanden/meta.properties– that is where the demo keeps itsshow…flags. Site-wide they would also apply to the search page and to any lecture added later in its own folder.- Name the entry topic
index.md. Then/de/and/en/serve that page directly instead of a redirect, and the topic keeps its place in the sidebar like any other. Itsnavorderstill decides the order – the file name does not. - The site root. With a published
content/index.mdthe tile page stays the landing page. Unpublishing it (publish=falsein its front matter, as in this repository) makesIndexRedirectswrite/index.htmlas a redirect into the lecture instead: a static page with<meta http-equiv="refresh">, whose script picksde/oren/by the reader's language. The Start button in the header then also leads back into the lecture, since it points at/. Note that the[LECTURES]tiles go away with the start page – further lectures are then only reachable by search or a direct link. - Both layouts can be mixed: a lecture at the content root and further ones in their own folders next to it.
The publish attribute (default true) controls whether a page is generated. publish=false
can appear in a .md's front matter or in a meta.properties at any level; a false at a
higher level overrides the lower ones. An unpublished page appears neither in the output nor in
the menu/sidebar.
# content/<lecture>/meta.properties – hides the whole lecture
publish=falseThe start page is subject to the same rule: publish=false in content/index.md leaves the
site without a baked index.html, which is what turns the site root into a redirect into the
lecture – see One lecture without a lecture folder.
That is how this repository is configured.
Every lecture page can be shown either as a continuous document or as a slide deck, and a
switch in the header toggles between the two. Setting documentOnly=true in a lecture's
meta.properties removes that switch: the lecture is then available only in the document view –
for both on-screen display and printing (the slide-per-page print layout is never used). The default
is false.
# content/<lecture>/meta.properties – no Slides view, document view only
documentOnly=trueThree controls of the header can be switched off individually. All three default to true;
only the literal value false hides the control. Like every other metadata key they follow the
cascade: in content/meta.properties they apply to the whole site, in a lecture's
content/<lecture>/meta.properties only to that lecture (and a single .md's front matter can
still override them for one page).
| Key | Hides |
|---|---|
showViewSwitch |
the Document/Slides switch in the top right |
showFullscreen |
the fullscreen button in the top right |
showHeaderToggle |
the arrow in the top left that collapses/expands the header |
# content/meta.properties – site-wide, or in content/<lecture>/meta.properties per lecture
showViewSwitch=false
showFullscreen=false
showHeaderToggle=falseNotes:
showViewSwitch=falseremoves only the control; the page keeps whatever view is currently stored (or is requested via?view=doc/?view=slides), and the slide navigation keeps working in the slide view. UsedocumentOnly=trueif a lecture should really be document-only – that hides the switch and pins the document view (including for printing).showHeaderToggle=falsealso means the header can no longer be collapsed: a stored or URL-supplied collapsed state (?hd=c) is ignored, so the header cannot get stuck in a state with no way back.- The night-mode button, the language switch, the search field and the sidebar toggle are not affected; they stay available.
The interface can be switched independently of the content language (DE/EN, top right).
Translations live as properties files under content/ (never under assets/):
| File | Content |
|---|---|
content/site_de.properties, content/site_en.properties |
site-wide interface texts (menu, buttons, footer) |
content/<name>_de.properties, content/<name>_en.properties |
page-specific texts (e.g. index_en.properties) |
content/search_de.properties, content/search_en.properties |
search texts |
MetaMerge bundles these into a window.I18N = { de: …, en: … } table; assets/js/i18n.js
uses it to swap texts, titles and (language-dependent) links in the browser. If a translation
is missing, the German source value remains – so switching to EN always works, even for
lectures without English pages (the content then falls back to the available language).
The German table works the same way: a key in site_de.properties overrides the wording
built into the templates, so a site can rename interface terms without touching them. A site
about something other than lectures renames the sidebar heading like this:
# content/site_de.properties # content/site_en.properties
sidebar.lecture=Menü # sidebar.lecture=Menu
sidebar.topics=Seitenmenü # sidebar.topics=Site menuKeys that are not listed keep the template wording, so a partial file is fine.
Everything the reader switches is kept in the address, not only in the browser’s storage, and
is appended to every internal link (assets/js/i18n.js) so that it survives navigation:
| Parameter | Meaning |
|---|---|
lang |
interface language, de or en |
sb |
sidebar, e (expanded) or c (collapsed) |
hd |
header, e or c – only where the header toggle exists |
view |
doc or slides – only on lecture pages that have both |
theme |
light or dark |
The rule is the same for all of them: the URL parameter wins, then the stored choice, then
the default. The script in the page head applies them before the first paint, so nothing
flickers, and each storage access is guarded on its own – where site data is blocked (private
window, file://) a ?theme=dark still works instead of the page falling back to light.
theme is the one exception to always being appended: it travels only once the reader has
actually chosen an appearance (a click, or ?theme= in the address). Without that choice the
system setting keeps deciding, and a single internal click does not silently pin it.
In the text, images are referenced by their file name only (); an
images/ prefix is not required – if a directory is missing, page.ftl adds images/
automatically during the build. ImageCascade resolves the actual location via a cascade
(later entries override earlier ones for equal names):
content/images/– site-widecontent/<lecture>/common/images/– all languages of the lecturecontent/<lecture>/<lang>/images/– language-specific
For a lecture without a lecture folder the same
cascade applies one level up (content/common/images/, content/<lang>/images/).
In addition, content/images/ is copied verbatim into the output root
target/website/images/. That is where site-wide, institution-specific content such as the
logo (hhn-logo.png, hhn-logo-en.png) and the favicon live – deliberately under
content/, not under assets/.
An image that stands alone in a paragraph is a content image and is centred
automatically – page.ftl gives that paragraph the class img-only. An image that shares
its paragraph or list item with text (a flag icon in front of a link, say) stays in the text
flow and is left untouched, because centring would tear such a line apart.
To align a single image differently, end its title with |left, |center or |right:

The marker is removed from the title during the build (it must not end up in the tooltip);
if nothing is left of the title, the title attribute is dropped altogether. The image gets
the class img-align-<pos> and its paragraph align-<po
0 comments
log in to comment.