diff --git a/.github/workflows/generate-documentation.yml b/.github/workflows/generate-documentation.yml new file mode 100644 index 00000000..e16a255b --- /dev/null +++ b/.github/workflows/generate-documentation.yml @@ -0,0 +1,17 @@ +name: Generate documentation +on: + push: + paths: + - 'documentation-tools/**' + - DOCUMENTATION.md + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: gh-pages + - name: Run generator + run: documentation-tools/generate.py + diff --git a/beta/assets/default.css b/beta/assets/default.css new file mode 100644 index 00000000..b58fecfa --- /dev/null +++ b/beta/assets/default.css @@ -0,0 +1,107 @@ +/* https://www.w3.org/TR/CSS22/ */ +html, address, +blockquote, +body, dd, div, +dl, dt, fieldset, form, +frame, frameset, +h1, h2, h3, h4, +h5, h6, noframes, +ol, p, ul, center, +dir, hr, menu, pre { display: block; unicode-bidi: embed } +li { display: list-item } +head { display: none } +table { display: table } +tr { display: table-row } +thead { display: table-header-group } +tbody { display: table-row-group } +tfoot { display: table-footer-group } +col { display: table-column } +colgroup { display: table-column-group } +td, th { display: table-cell } +caption { display: table-caption } +th { font-weight: bolder; text-align: center } +caption { text-align: center } +body { margin: 8px } +h1 { font-size: 2em; margin: .67em 0 } +h2 { font-size: 1.5em; margin: .75em 0 } +h3 { font-size: 1.17em; margin: .83em 0 } +h4, p, +blockquote, ul, +fieldset, form, +ol, dl, dir, +menu { margin: 1.12em 0 } +h5 { font-size: .83em; margin: 1.5em 0 } +h6 { font-size: .75em; margin: 1.67em 0 } +h1, h2, h3, h4, +h5, h6, b, +strong { font-weight: bolder } +blockquote { margin-left: 40px; margin-right: 40px } +i, cite, em, +var, address { font-style: italic } +pre, tt, code, +kbd, samp { font-family: monospace } +pre { white-space: pre } +button, textarea, +input, select { display: inline-block } +big { font-size: 1.17em } +small, sub, sup { font-size: .83em } +sub { vertical-align: sub } +sup { vertical-align: super } +table { border-spacing: 2px; } +thead, tbody, +tfoot { vertical-align: middle } +td, th, tr { vertical-align: inherit } +s, strike, del { text-decoration: line-through } +hr { border: 1px inset } +ol, ul, dir, +menu, dd { margin-left: 40px } +ol { list-style-type: decimal } +ol ul, ul ol, +ul ul, ol ol { margin-top: 0; margin-bottom: 0 } +u, ins { text-decoration: underline } +br:before { content: "\A"; white-space: pre-line } +center { text-align: center } +:link, :visited { text-decoration: underline } +:focus { outline: thin dotted invert } + +/* Begin bidirectionality settings (do not change) */ +BDO[DIR="ltr"] { direction: ltr; unicode-bidi: bidi-override } +BDO[DIR="rtl"] { direction: rtl; unicode-bidi: bidi-override } + +*[DIR="ltr"] { direction: ltr; unicode-bidi: embed } +*[DIR="rtl"] { direction: rtl; unicode-bidi: embed } + +@media print { + h1 { page-break-before: always } + h1, h2, h3, + h4, h5, h6 { page-break-after: avoid } + ul, ol, dl { page-break-before: avoid } +} +/* END: https://www.w3.org/TR/CSS22/ */ + +@media not screen { + header select.documentBranch, nav, .headerlink { + display: none; + } + + p.heading { + font-size: .67em; + margin: 2em 0; + font-weight: bolder; + } + + nav { + /* Override changes made by JS to HTMLElement */ + max-height: none !important; + } +} + +@media print { + h1 { + page-break-before: auto; + } + + p.heading { + page-break-after: avoid; + } +} diff --git a/beta/assets/document-branches.js b/beta/assets/document-branches.js new file mode 100644 index 00000000..706d1764 --- /dev/null +++ b/beta/assets/document-branches.js @@ -0,0 +1,20 @@ +window.addEventListener("load", function() { + let select = document.querySelector("header select.documentBranch"); + while (select.firstChild) { + select.removeChild(select.firstChild); + } + for (let branch of documentBranches) { + let option = document.createElement("option"); + option.textContent = branch; + if (branch === documentBranch) { + option.setAttribute("selected", ""); + } + select.appendChild(option); + } + select.addEventListener("change", function() { + if (select.value !== documentBranch) { + location.assign(select.value + ".html"); + select.value = documentBranch; + } + }); +}); diff --git a/beta/assets/icon.png b/beta/assets/icon.png new file mode 100644 index 00000000..a9c9c042 Binary files /dev/null and b/beta/assets/icon.png differ diff --git a/beta/assets/logo.svg b/beta/assets/logo.svg new file mode 100644 index 00000000..546d3d10 --- /dev/null +++ b/beta/assets/logo.svg @@ -0,0 +1,10 @@ + + diff --git a/beta/assets/navigation.js b/beta/assets/navigation.js new file mode 100644 index 00000000..6a752fda --- /dev/null +++ b/beta/assets/navigation.js @@ -0,0 +1,67 @@ +window.addEventListener("load", function() { + function findActiveSection(sections) { + let result = sections[0]; + for (let [section, link] of sections) { + if (section.getBoundingClientRect().y > 10) { + break; + } + result = [section, link]; + } + return result; + } + + let nav = document.querySelector("nav"); + let sections = new Array(); + sections.push([document.querySelector("main"), null]); + for (let section of document.querySelectorAll("section")) { + let id = section.getAttribute("id"); + let link = nav.querySelector("a[href=\\#" + id.replace(/\//g, "\\/") + "]"); + if (link !== null) { + link = link.parentElement; + link.classList.remove("active") + sections.push([section, link]); + } + } + let oldLink = null; + function updateLink() { + let [section, link] = findActiveSection(sections); + while (oldLink) { + if (oldLink.tagName === "LI") { + oldLink.classList.remove("active"); + if (!oldLink.classList.contains("level4")) { + break; + } + } + oldLink = oldLink.parentElement; + } + oldLink = link; + while (link) { + if (link.tagName === "LI") { + link.classList.add("active"); + if (!link.classList.contains("level4")) { + break; + } + } + link = link.parentElement; + } + if (link === null) { + nav.scrollTop = 0; + } else { + let topLink = link.getBoundingClientRect().y; + let topNav = nav.getBoundingClientRect().y; + let y = nav.scrollTop + topLink - topNav - 10; + nav.scrollTo(0, y); + } + } + function resizeNav() { + let height = window.innerHeight - nav.getBoundingClientRect().y; + nav.style.maxHeight = height > 0 ? height + "px" : "none"; + } + function updateNav() { + resizeNav(); + updateLink(); + } + document.addEventListener("scroll", updateNav); + window.addEventListener("resize", updateNav); + updateNav(); +}); diff --git a/beta/assets/screen-noscript.css b/beta/assets/screen-noscript.css new file mode 100644 index 00000000..aa3d6b10 --- /dev/null +++ b/beta/assets/screen-noscript.css @@ -0,0 +1,27 @@ +header select.documentBranch { + display: none; +} + +header span.documentBranch { + display: inline; +} + +nav { + position: relative; + height: auto; + /* Override changes made by JS to HTMLElement */ + max-height: none !important; +} + +nav .level2.active > a, nav .level3.active > a { + background: none; + border-left-color: transparent; +} + +nav .level4 { + display: initial; +} + +nav .level4 > a::after { + display: none !important; +} diff --git a/beta/assets/screen.css b/beta/assets/screen.css new file mode 100644 index 00000000..f49f03d2 --- /dev/null +++ b/beta/assets/screen.css @@ -0,0 +1,349 @@ +html, body { + margin: 0; + padding: 0; + font-family: sans-serif; + line-height: 1.4; + background-color: #E4E9F6; + color: #424247; +} + +body { + display: flex; + flex-direction: column; + align-items: center; +} + +body > * { + width: 100%; +} + +a { + color: #a40000; +} + +a:link { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +header { + display: flex; + flex-direction: column; + background-color: #050a02; + color: #efdddd; + align-items: center; +} + +header > * { + width: 100%; +} + +header .logoContainer { + max-width: 1400px; + padding: 2em 10px; + padding-left: calc(210px + 2em); + box-sizing: border-box; + background: url(logo.svg); + background-repeat: no-repeat; + background-position: 10px 50%; + min-height: calc(300px + 4em); + display: flex; + flex-direction: column; + justify-content: center; +} + +header p { + margin: 0; + font-size: 1.5em; +} + +header h1 { + font-weight: lighter; + font-size: 3em; + margin-top: 0; + margin-bottom: 2rem; +} + +header span.documentBranch { + border: 1px dashed #efdddd; + padding: 0 5px; + display: none; +} + +header select.documentBranch { + background: none; + color: #efdddd; + border: none; + font-size: 1em; + font-weight: lighter; + font-family: sans-serif; + -webkit-appearance: none; + -moz-appearance: none; + border: 1px dashed #efdddd; + cursor: pointer; + padding: 0 5px; +} + +header select.documentBranch:hover { + text-decoration: underline; +} + +header select.documentBranch option { + color: initial; + font-size: initial; + font-weight: initial; + font-family: initial; + cursor: initial; +} + +header .linkContainer { + order: -1; + background-color: #a40000; + flex-direction: column; + display: flex; + align-items: center; +} + +header ul { + list-style-type: none; + margin: 0; + padding: 0; + display: flex; + max-width: 1400px; + width: 100%; + justify-content: space-between; +} + +header a { + color: white; + padding: 10px; + display: flex; + justify-content: center; + flex-direction: column; + text-align: center; + transition: color .2s ease; + height: 100%; + box-sizing: border-box; +} + +header a:link { + text-decoration: none; +} + +header a:hover { + color: #050a02; +} + +main { + flex: 1; + max-width: 1400px; + display: flex; +} + +/* Shift headers by 1 */ +h2 { + font-size: 2em; + margin: .67em 0; +} + +h3 { + font-size: 1.5em; + margin: .75em 0; +} + +h4 { + font-size: 1.17em; + margin: .83em 0; +} + +h5 { + font-size: 1em; + margin: 1.12em 0; +} + +h6 { + font-size: .83em; + margin: 1.5em 0; +} + +p.heading { + font-size: .75em; + margin: 1.67em 0; + font-weight: bolder; +} + +img, .tableContainer { + max-width: 100%; +} + +.tableContainer { + overflow-x: scroll; +} + +blockquote { + border-radius: 3px; + background-color: #ECE7D5; + padding: 10px; + border-left: 3px solid #F6E39A; + margin-left: 0; + margin-right: 0; +} + +blockquote > *:first-child { + margin-top: 0; +} + +blockquote > *:last-child { + margin-bottom: 0; +} + +div.sourceCode { + border-radius: 3px; + background-color: #D2D7E3; + padding: 10px; + border-left: 3px solid #a40000; +} + +:not(pre) > code { + border-radius: 2px; + background-color: #D2D7E3; + padding: 0 2px; +} + +section.last { + min-height: 100vh; +} + +nav h2 { + font-weight: normal; + padding: 0 10px; + font-size: 1.5em; + margin: .75em 0; +} + +nav ul { + list-style-type: none; + margin: 0; + padding: 0; +} + +nav { + padding: 10px 0; + margin-right: 2rem; + flex: 1; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + scrollbar-width: none; +} + +nav::-webkit-scrollbar { + display: none; +} + +nav .level2 { + padding-bottom: 20px; +} + +nav a { + display: block; + padding: 5px 10px; + padding-left: 7px; + border-left: 3px solid transparent; + border-radius: 3px; +} + +nav .level2 > a { + font-weight: bolder; +} + +nav .level4.active > a::after { + content: "▷"; + color: #424247; + position: absolute; + top: 0; + left: 10px; + height: 100%; + width: 20px; + font-size: 0.8em; + display: flex; + justify-content: center; + align-items: center; +} + +nav .level4 > a { + color: #424247; + padding-left: 30px; + position: relative; +} + +nav .level2.active > a, nav .level3.active > a { + background: #D2D7E3; + border-left-color: #a40000; +} + +nav .level4 { + display: none; +} + +nav .active > ul > .level4 { + display: initial; +} + +main p, main ul, main ol { + max-width: 42em; + box-sizing: border-box; + margin-left: 0; +} + +.documentContainer { + padding: 10px; + box-sizing: border-box; + width: 75%; +} + +table, tr, td, th, thead { + border: 1px solid #424247; + border-collapse: collapse; +} + +thead { + background-color: #D2D7E3; +} + +td, th { + padding: 5px 10px; +} + +.headerlink { + display: none; +} + +section > *:first-child:hover .headerlink { + display: initial; +} + +@media all and (max-width: 50em) { + nav { + display: none; + } + + .documentContainer { + width: 100%; + } + + header .logoContainer { + padding: 2em 10px; + background-position: 50%; + min-height: 320px; + text-align: center; + } + + header .logoContainer, header select.documentBranch { + text-shadow: 0 0 3px #050a02, 0 0 3px #050a02; + } +} diff --git a/documentation-tools/filter.py b/documentation-tools/filter.py new file mode 100755 index 00000000..4a4ebf7a --- /dev/null +++ b/documentation-tools/filter.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +""" +Filter program for transforming the Pandoc AST +""" + +import json +import re +import sys + +from build import SHIFT_HEADING + + +def text_content(content): + text = "" + for block in content: + if block["t"] == "Space": + text += " " + elif block["t"] == "Str": + text += block["c"] + return text + + +def convert_framgent(*titles): + titles = list(titles) + for i, title in enumerate(titles): + title = re.sub(r"\s", "-", title) + title = re.sub(r"[^\w-]", "", title) + titles[i] = title.lower() + return "/".join(titles) + + +def main(): + data = json.load(sys.stdin) + + # Use hierachical link fragments (e.g. #heading/subheading) + headings = [] + for block in data["blocks"]: + if block["t"] != "Header": + continue + level, (attr_id, attr_class, attr_name), content = block["c"] + shifted_level = level - SHIFT_HEADING + title = text_content(content) + headings = headings[:shifted_level - 1] + while len(headings) < shifted_level - 1: + headings.append("") + headings.append(title) + full_attr_id = convert_framgent(*headings) + block["c"] = [level, [full_attr_id, attr_class, attr_name], content] + + json.dump(data, sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/documentation-tools/generate.py b/documentation-tools/generate.py new file mode 100755 index 00000000..ccde4cee --- /dev/null +++ b/documentation-tools/generate.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +""" +Documentation generator + +Generates the documentation for every Git branch and commits it. +Gracefully handles conflicting commits. +""" + +import contextlib +import glob +import os +import re +import shutil +import subprocess +import sys +from tempfile import TemporaryDirectory + +REMOTE = "origin" +GIT_CONFIG = {"protocol.version": "2", + "user.email": "<>", + "user.name": "Github Actions"} +COMMIT_MESSAGE = "Generate documentation" +DOCUMENTATION_SRC = "DOCUMENTATION.md" +TARGET_DIR = "beta" +SHIFT_HEADING = 1 +TOOLS_PATH = os.path.dirname(__file__) +TEMPLATE_PATH = os.path.join(TOOLS_PATH, "template.html") +FILTER_EXE = os.path.join(TOOLS_PATH, "filter.py") +POSTPROCESSOR_EXE = os.path.join(TOOLS_PATH, "postprocessor.py") +PANDOC_EXE = "pandoc" +PANDOC_DOWNLOAD = "https://github.com/jgm/pandoc/releases/download/2.9.2/pandoc-2.9.2-1-amd64.deb" + + +def convert_doc(src_path, to_path, branch, branches): + subprocess.run([ + PANDOC_EXE, + "--from=gfm", + "--to=html5", + os.path.abspath(src_path), + "--toc", + "--template=%s" % os.path.basename(TEMPLATE_PATH), + "--output=%s" % os.path.abspath(to_path), + "--section-divs", + "--shift-heading-level-by=%d" % SHIFT_HEADING, + "--toc-depth=4", + "--filter=%s" % os.path.abspath(FILTER_EXE), + "--variable=branch=%s" % branch, + *["--variable=branches=%s" % b for b in branches]], + check=True, cwd=os.path.dirname(TEMPLATE_PATH)) + with open(to_path, "rb+") as f: + data = subprocess.run([POSTPROCESSOR_EXE], input=f.read(), + stdout=subprocess.PIPE, check=True).stdout + f.seek(0) + f.truncate() + f.write(data) + + +def install_dependencies(): + subprocess.run([sys.executable, "-m", "pip", "install", "beautifulsoup4"], + check=True) + with TemporaryDirectory() as temp: + subprocess.run(["curl", "--location", "--output", "pandoc.deb", + PANDOC_DOWNLOAD], check=True, cwd=temp) + subprocess.run(["apt", "install", "--assume-yes", "./pandoc.deb"], + check=True, cwd=temp) + + +def natural_sort_key(s): + # https://stackoverflow.com/a/16090640 + return [int(part) if part.isdigit() else part.lower() + for part in re.split(r"(\d+)", s)] + + +def run_git(*args): + config_args = [] + for key, value in GIT_CONFIG.items(): + config_args.extend(["-c", "%s=%s" % (key, value)]) + output = subprocess.run(["git", *config_args, *args], + stdout=subprocess.PIPE, check=True, + universal_newlines=True).stdout + return tuple(filter(None, output.split("\n"))) + + +def checkout(branch): + run_git("checkout", "--progress", "--force", "-B", branch, + "refs/remotes/%s/%s" % (REMOTE, branch)) + + +def run_git_fetch_and_restart_if_changed(remote_commits, target_branch): + run_git("fetch", "--no-tags", "--prune", "--progress", + "--no-recurse-submodules", "--depth=1", REMOTE, + "+refs/heads/*:refs/remotes/%s/*" % REMOTE) + if remote_commits != run_git("rev-parse", "--remotes=%s" % REMOTE): + checkout(target_branch) + print("Remote changed, restarting", file=sys.stderr) + os.execv(__file__, sys.argv) + + +def main(): + install_dependencies() + target_branch, = run_git("rev-parse", "--abbrev-ref", "HEAD") + remote_commits = run_git("rev-parse", "--remotes=%s" % REMOTE) + run_git_fetch_and_restart_if_changed(remote_commits, target_branch) + branches = [ref[len("refs/remotes/%s/" % REMOTE):] for ref in run_git( + "rev-parse", "--symbolic-full-name", "--remotes=%s" % REMOTE)] + branches.sort(key=natural_sort_key, reverse=True) + os.makedirs(TARGET_DIR, exist_ok=True) + for path in glob.iglob(os.path.join(TARGET_DIR, "*.html")): + run_git("rm", "--", path) + with TemporaryDirectory() as temp: + branch_docs = {} + for branch in branches: + checkout(branch) + if os.path.exists(DOCUMENTATION_SRC): + branch_docs[branch] = os.path.join(temp, "%s.md" % branch) + shutil.copy(DOCUMENTATION_SRC, branch_docs[branch]) + checkout(target_branch) + for branch, src_path in branch_docs.items(): + to_path = os.path.join(TARGET_DIR, "%s.html" % branch) + convert_doc(src_path, to_path, branch, branches) + run_git("add", "--", to_path) + with contextlib.suppress(subprocess.CalledProcessError): + run_git("diff", "--cached", "--quiet") + print("No changes", file=sys.stderr) + return + run_git("commit", "-m", COMMIT_MESSAGE) + try: + run_git("push", REMOTE, "HEAD:%s" % target_branch) + except subprocess.CalledProcessError: + run_git_fetch_and_restart_if_changed(remote_commits, target_branch) + raise + + +if __name__ == "__main__": + main() diff --git a/documentation-tools/postprocessor.py b/documentation-tools/postprocessor.py new file mode 100755 index 00000000..a17511cc --- /dev/null +++ b/documentation-tools/postprocessor.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +""" +Postprocessor program for the HTML output of Pandoc +""" + +import sys + +from bs4 import BeautifulSoup + + +def add_class(element, class_): + element["class"] = element.get("class", []) + [class_] + + +def main(): + soup = BeautifulSoup(sys.stdin.buffer, "html.parser") + + # Mark the hierachical levels in the navigation + for section in soup.select("section"): + link = soup.find("a", href="#" + section["id"]) + if link is None: + continue + add_class(link.parent, section["class"][0]) + + # Mark last section + add_class(soup.select("section")[-1], "last") + + # Wrap tables in a div container (for scrolling) + for table in soup.select("main table"): + container = soup.new_tag("div") + add_class(container, "tableContainer") + table.wrap(container) + + # Add a link with the fragment to every header + for header in soup.select("section > *:first-child"): + section = header.parent + link = soup.new_tag("a") + add_class(link, "headerlink") + link["href"] = "#" + section["id"] + link.string = "¶" + header.append(" ") + header.append(link) + + sys.stdout.buffer.write(soup.encode(formatter="html5") + b"\n") + + +if __name__ == "__main__": + main() diff --git a/documentation-tools/template.html b/documentation-tools/template.html new file mode 100644 index 00000000..b4c8b4f7 --- /dev/null +++ b/documentation-tools/template.html @@ -0,0 +1,53 @@ + + + + + + + + +$if(math)$ + $math$ +$endif$ + + + + + +
Free and Open-Source CalDAV and CardDAV Server
+