#!/usr/bin/env python import os import pathlib import shutil import sys from collections.abc import Callable import yaml from bs4 import BeautifulSoup def get_nav_from_selector(soup: BeautifulSoup, selector: str, docs_dir: str): """ Generates navigation from template file """ navigation = [] header_name = "" for anchor in soup.select(selector): classes = anchor.get("class", []) if classes is None: continue if "disabled" in classes: header_name = anchor.text navigation.append({header_name: []}) href = anchor.get("href", "") if href is None: continue href = str(href) if href == "#": continue url = href.replace("{{NAME}}", "") filename = url.replace("http://progrium.viewdocs.io/dokku/", "") filename = filename.replace("http://dokku.viewdocs.io/dokku/", "") filename = filename.strip("/") + ".md" filename = filename.removeprefix("dokku/") ignore_errors_for = [ "getting-started/installation.md", "getting-started/upgrading.md", ] if not os.path.exists(docs_dir + "/" + filename): if filename not in ignore_errors_for: print("error fetching markdown file:", filename) continue for nav in navigation: if header_name in nav: if filename == "getting-started/installation.md": child_dir = "getting-started/install/" children = os.listdir(docs_dir + "/" + child_dir) children = [child_dir + c for c in children] children.sort() children.insert(0, "getting-started/advanced-installation.md") if os.path.exists(docs_dir + "/" + filename): children.insert(0, filename) else: children.insert(0, "getting-started/installation/index.md") nav[header_name].append( { "Getting Started with Dokku": children, } ) continue if filename == "getting-started/upgrading.md": child_dir = "appendices/" children = os.listdir(docs_dir + "/" + child_dir) children.sort( key=lambda x: list(map(int, x.split("-")[0].split("."))), reverse=True, ) children = [child_dir + c for c in children] if os.path.exists(docs_dir + "/" + filename): children.insert(0, filename) else: children.insert(0, "getting-started/upgrading/index.md") nav[header_name].append( { "Upgrading": children, } ) continue nav[header_name].append(filename) return navigation def generate_nav(src: str, dest: str) -> None: """ Writes out navigation information to the mkdocs yaml file """ navigation = [] repo_dir = pathlib.Path(__file__).parent.parent.resolve() docs_dir = str(repo_dir) + "/docs" with open(docs_dir + "/template.html", encoding="utf-8") as response: soup = BeautifulSoup(response, "html.parser") selectors = [ ".container .row .list-group a", ".container-fluid .row .list-group a", ] for selector in selectors: navigation = get_nav_from_selector(soup, selector, docs_dir) if len(navigation) > 0: break if len(navigation) == 0: print("No navigation found") sys.exit(1) with open(src, encoding="utf-8") as handler: data = yaml.unsafe_load(handler) data["nav"] = [ {"Docs": navigation}, {"Pro": "https://pro.dokku.com/docs/getting-started/"}, {"Blog": "https://dokku.com/blog/"}, {"Tutorials": "https://dokku.com/tutorials/"}, { "Purchase Dokku Pro": "https://dokku.dpdcart.com/cart/add?product_id=217344&method_id=236878" }, ] with open(dest, mode="wt", encoding="utf-8") as handler: yaml.dump(data, handler) def modify_content_noop(lines: list[str], _) -> tuple[list[str], bool]: """ Simply returns the lines as is """ modified = False updated_lines = [] for line in lines: updated_lines.append(line) return updated_lines, modified def modify_content_links(lines: list[str], filename: str) -> tuple[list[str], bool]: """ Modifies links to be relative instead of absolute """ filename = filename.replace("/usr/src/source/docs/", "") parts = filename.split("/") parts.pop() replacement = "](" + "/".join([".." for _ in parts]) + "/" modified = False updated_lines = [] for line in lines: if "](/docs/" in line: line = line.replace("](/docs/", replacement) modified = True updated_lines.append(line) return updated_lines, modified def modify_content_stripspace(lines: list[str], _) -> tuple[list[str], bool]: """ Removes trailing whitespace from each line """ modified = False updated_lines = [] for line in lines: line = line.rstrip() updated_lines.append(line) return updated_lines, modified def modify_content_inject_newlines(lines: list[str], _) -> tuple[list[str], bool]: """ Ensures every line has a trailing newline character """ modified = False updated_lines = [] for line in lines: updated_lines.append(line.rstrip() + "\n") return updated_lines, modified def is_github_new(line: str, next_line: str | None) -> bool: """ Checks if a given line is a github "new as of" admonition """ if not next_line: return False return line.startswith("> [!IMPORTANT]") and "new as of" in next_line.lower() def is_github_note(line: str) -> bool: """ Checks if a given line is a github "note" admonition """ return line.startswith("> [!NOTE]") def is_github_warning(line: str) -> bool: """ Checks if a given line is a github "warning" admonition """ return line.startswith("> [!WARNING]") def is_info(line: str) -> bool: """ Checks if a given line is an "info" admonition """ return line.startswith("> ") def is_new(line: str) -> bool: """ Checks if a given line is a "new as of" admonition """ return line.startswith("> ") and "new as of" in line.lower() def is_note(line: str) -> bool: """ Checks if a given line is a "note" admonition """ return line.startswith("> Note:") def is_warning(line: str) -> bool: """ Checks if a given line is a "warning" admonition """ return line.startswith("> Warning:") def modify_content_admonition(lines: list[str], _) -> tuple[list[str], bool]: """ Applies adminition info to each line in the output """ modified = False updated_lines = [] admonition_lines = [] is_admonition = False replace_new_in_next_line = False for index, line in enumerate(lines): next_line: str | None = None if index + 1 < len(lines): next_line = lines[index + 1] if replace_new_in_next_line: line = line.replace("New as of", "Introduced in") line = line.replace("new as of", "introduced in") replace_new_in_next_line = False if is_github_new(line, next_line): line = line.replace("> [!IMPORTANT]", '!!! tip "New"') is_admonition = True admonition_lines.append(line) admonition_lines.append("") replace_new_in_next_line = True elif is_github_note(line): line = line.replace("> [!NOTE]", '!!! note "Note"') is_admonition = True admonition_lines.append(line) admonition_lines.append("") elif is_github_warning(line): line = line.replace("> [!WARNING]", '!!! warning "Warning"') is_admonition = True admonition_lines.append(line) admonition_lines.append("") elif is_new(line): print("is_new") line = line.replace("> ", '!!! tip "New"\n\n ') line = line.replace("New as of", "Introduced in") line = line.replace("new as of", "introduced in") is_admonition = True admonition_lines.append(line) elif is_note(line): print("is_note") line = line.replace("> Note: ", '!!! note "Note"\n\n ') is_admonition = True admonition_lines.append(line) elif is_warning(line): print("is_warning") line = line.replace("> Warning: ", '!!! warning "Warning"\n\n ') is_admonition = True admonition_lines.append(line) elif is_info(line): if not is_admonition: line = line.replace("> ", '!!! info "Info"\n\n ') elif line in [">", "> "]: line = "" else: line = " " + line.removeprefix("> ") is_admonition = True admonition_lines.append(line) elif is_admonition and line in [">", "> "]: line = "" admonition_lines.append(line) elif is_admonition and line.startswith("> "): line = " " + line.removeprefix("> ") admonition_lines.append(line) elif line == "": is_admonition = False if len(admonition_lines) > 0: modified = True updated_lines.extend(admonition_lines) admonition_lines = [] updated_lines.append("") else: updated_lines.append(line) return updated_lines, modified def is_shell_codeblock_start(line: str) -> bool: """ Checks to see if a line starts a codeblock """ return line == "```shell" def modify_content_terminal_example(lines: list[str], _) -> tuple[list[str], bool]: """ Modifies content so that terminal output is shown appropriately """ modified = False updated_lines = [] command_block = [] example_block = [] in_command_block = False in_example_block = False previous_block = "" next_line_must_be = None for line in lines: if is_shell_codeblock_start(line): command_block.append(line) modified = True in_command_block = True continue elif in_command_block: command_block.append(line) if line == "```": in_command_block = False previous_block = "command_block" next_line_must_be = "" continue elif line == "```": if previous_block == "": updated_lines.append(line) continue if previous_block == "command_block": example_block.append(line) if in_example_block: previous_block = "" in_example_block = False updated_lines.append('=== "Shell"') updated_lines.append("") for command_line in command_block: updated_lines.append(f" {command_line}") command_block = [] updated_lines.append("") updated_lines.append('=== "Output"') updated_lines.append("") for example_line in example_block: updated_lines.append(f" {example_line}") example_block = [] else: in_example_block = True continue elif previous_block == "command_block": if next_line_must_be is None: if in_example_block: example_block.append(line) else: updated_lines.extend(command_block) updated_lines.append("") updated_lines.append(line) command_block = [] previous_block = "" continue if next_line_must_be == "": if line == "": next_line_must_be = None continue updated_lines.append(line) if len(command_block) > 0: updated_lines.extend(command_block) return updated_lines, modified def update_markdown(src): """ Updates all markdown files to be mkdocs compatible """ markdown_files = [] allowed_extensions = [".md"] for subdir, _, files in os.walk(src): for file in files: ext = os.path.splitext(file)[-1].lower() file_path = os.path.join(subdir, file) if ext in allowed_extensions: markdown_files.append(file_path) modifiers: list[Callable[[list[str], str], tuple[list[str], bool]]] = [ modify_content_noop, modify_content_links, modify_content_stripspace, modify_content_admonition, modify_content_terminal_example, modify_content_inject_newlines, ] for file in markdown_files: is_modified = False lines = [] with open(file, "r", encoding="utf-8") as handler: lines = handler.readlines() for modifier in modifiers: lines, modified = modifier(lines, file) if modified: is_modified = True if is_modified: with open(file, "w", encoding="utf-8") as handler: handler.writelines(lines) def main(): """ Main command that performs doc manipulation """ print("----> Copying mkdocs.yml") print(" Generating navigation") generate_nav("/usr/src/source/mkdocs.yml", "/usr/src/app/mkdocs.yml") print("----> Copying docs folder") update_markdown( "/usr/src/source/docs", ) if os.path.exists("/usr/src/app/docs"): print(" Removing old docs folder") shutil.rmtree("/usr/src/app/docs") print(" Performing copy") shutil.copytree("/usr/src/source/docs", "/usr/src/app/docs") if __name__ == "__main__": main()