From b36e689be37ec2ac8c500c7c899de2bbe3a8468b Mon Sep 17 00:00:00 2001 From: lukelowry Date: Sun, 7 Jun 2026 23:03:55 -0500 Subject: [PATCH 1/3] Initial RTD interfacing setup --- .gitignore | 6 + .readthedocs.yaml | 20 + docs/Doxyfile | 14 + docs/README.md | 57 +- docs/api.md | 5 + docs/conf.py | 33 + docs/generate_model_docs.py | 574 ++++++++++++++++++ docs/index.md | 21 + docs/models/index.md | 21 + docs/project/index.md | 23 + docs/requirements.txt | 4 + .../Tiny/TwoBus/Gensal/README.md | 4 +- 12 files changed, 755 insertions(+), 27 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 docs/Doxyfile create mode 100644 docs/api.md create mode 100644 docs/conf.py create mode 100644 docs/generate_model_docs.py create mode 100644 docs/index.md create mode 100644 docs/models/index.md create mode 100644 docs/project/index.md create mode 100644 docs/requirements.txt diff --git a/.gitignore b/.gitignore index fc8ef955b..3860c865d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,10 @@ build/ *.swp doxygen-docs/ +docs/_build/ +docs/api/generated/ +docs/examples/generated/ +docs/generated/ +docs/models/generated/ +docs/xml/ __pycache__ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..506264ffd --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,20 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + apt_packages: + - doxygen + - graphviz + jobs: + pre_build: + - python docs/generate_model_docs.py + - cd docs && doxygen Doxyfile + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt diff --git a/docs/Doxyfile b/docs/Doxyfile new file mode 100644 index 000000000..98f1a96ec --- /dev/null +++ b/docs/Doxyfile @@ -0,0 +1,14 @@ +PROJECT_NAME = "GridKit" +OUTPUT_DIRECTORY = . + +INPUT = ../GridKit ../README.md +RECURSIVE = YES +USE_MDFILE_AS_MAINPAGE = ../README.md + +GENERATE_HTML = NO +GENERATE_LATEX = NO +GENERATE_XML = YES +XML_OUTPUT = xml + +EXTRACT_ALL = YES +QUIET = YES diff --git a/docs/README.md b/docs/README.md index 2242f220a..923ca5d1a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,32 +1,39 @@ -# GridKit Documentation Target +# GridKit Documentation -We use CMake's built-in `FindDoxygen` module to generate a target that will -use Doxygen to build the project documentation. +GridKit documentation can be built two ways: -## Building +1. Read the Docs builds the Sphinx site from `docs/conf.py`. +2. CMake can build the existing Doxygen HTML target. + +## Read the Docs Build + +The Read the Docs proof of concept is configured by `.readthedocs.yaml`. +Before Sphinx runs, the build generates Markdown wrapper pages and Doxygen XML: -The documentation target is excluded from the default set of targets built via -`make` (or `cmake --build .`). It must be designated explicitly with ```sh -make GridKitDocs +python docs/generate_model_docs.py +cd docs && doxygen Doxyfile ``` -or + +To test the same flow locally: + ```sh -cmake --build . -t GridKitDocs +python -m pip install -r docs/requirements.txt +python docs/generate_model_docs.py +cd docs && doxygen Doxyfile +cd .. +sphinx-build -T -b html docs docs/_build/html ``` -The generated files can be found in the build directory under `docs/html/` and -the main page is `docs/html/index.html`. - -If you wish to inspect the Doxyfile itself, it is generated as -`docs/Doxyfile.GridKitDocs`. - -## Notes -The reasoning behind taking this approach is as follows: - -1. It is easier to maintain just the few options we need to customize rather - than a whole Doxyfile (leave what can be generated out of the repo); if - another option is needed, it's easy to look it up. -2. Since the documentation can be seen as a "product" or "artifact" of the code, - it makes sense for it to be a buildable "target" -3. Generated files should not be placed in the source directory. The binary - directory makes sense for this, and the CMake target makes this easy. + +The generated Sphinx files, Doxygen XML, and HTML output are build artifacts and +should not be committed. + +## CMake Doxygen Target + +The existing CMake target is still available for standalone Doxygen HTML output: + +```sh +cmake --build build -t GridKitDocs +``` + +The generated files are written under the build directory. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 000000000..5218706cd --- /dev/null +++ b/docs/api.md @@ -0,0 +1,5 @@ +# API Reference + +```{doxygenindex} +:project: GridKit +``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..fcd5342e8 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,33 @@ +from pathlib import Path + +project = "GridKit" +author = "GridKit Developers" + +docs_dir = Path(__file__).parent.resolve() + +extensions = ["breathe", "myst_parser", "sphinx.ext.graphviz"] + +breathe_projects = {"GridKit": str(docs_dir / "xml")} +breathe_default_project = "GridKit" + +html_theme = "sphinx_rtd_theme" +html_extra_path = ["Figures"] +html_theme_options = { + "collapse_navigation": False, + "navigation_depth": 6, + "titles_only": True, +} + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +myst_enable_extensions = [ + "amsmath", + "dollarmath", + "html_image", +] +myst_heading_anchors = 5 + +exclude_patterns = ["_build", "xml", "README.md", "superpowers/**"] diff --git a/docs/generate_model_docs.py b/docs/generate_model_docs.py new file mode 100644 index 000000000..b3e34c9c4 --- /dev/null +++ b/docs/generate_model_docs.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +"""Generate Sphinx pages from GridKit Markdown documentation.""" + +from __future__ import annotations + +import os +import re +import shutil +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class MarkdownPage: + source: Path + output: Path + title: str + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DOCS_DIR = REPO_ROOT / "docs" +MODEL_DIR = REPO_ROOT / "GridKit" / "Model" +EXAMPLES_DIR = REPO_ROOT / "examples" +GITHUB_TREE_URL = "https://github.com/ORNL/GridKit/tree/develop" + +GENERAL_OUT_DIR = DOCS_DIR / "generated" +MODEL_OUT_DIR = DOCS_DIR / "models" / "generated" +EXAMPLE_OUT_DIR = DOCS_DIR / "examples" / "generated" + +COMMON_MATH = REPO_ROOT / "GridKit" / "CommonMath.md" +PHASOR_INPUT_FORMAT = MODEL_DIR / "PhasorDynamics" / "INPUT_FORMAT.md" + +PROJECT_DOCS = ( + MarkdownPage(REPO_ROOT / "README.md", GENERAL_OUT_DIR / "root-readme.md", "GridKit"), + MarkdownPage(REPO_ROOT / "INSTALL.md", GENERAL_OUT_DIR / "install.md", "Installation"), + MarkdownPage( + REPO_ROOT / "CONTRIBUTING.md", + GENERAL_OUT_DIR / "contributing.md", + "Contributing", + ), + MarkdownPage( + REPO_ROOT / "CHANGELOG.md", + GENERAL_OUT_DIR / "changelog.md", + "Changelog", + ), + MarkdownPage( + DOCS_DIR / "README.md", + GENERAL_OUT_DIR / "documentation-build.md", + "Documentation Build", + ), + MarkdownPage( + REPO_ROOT / "buildsystem" / "README.md", + GENERAL_OUT_DIR / "buildsystem.md", + "Buildsystem", + ), + MarkdownPage( + REPO_ROOT / "application" / "PhasorDynamics" / "README.md", + GENERAL_OUT_DIR / "phasor-dynamics-application.md", + "Phasor Dynamics Application", + ), +) + +PROJECT_DOC_BY_SOURCE = {page.source: page for page in PROJECT_DOCS} +EXAMPLE_READMES = frozenset(EXAMPLES_DIR.glob("**/README.md")) + + +def example_doc_dirs() -> set[Path]: + dirs = {EXAMPLES_DIR} + for readme in EXAMPLE_READMES: + directory = readme.parent + while directory != EXAMPLES_DIR.parent: + dirs.add(directory) + if directory == EXAMPLES_DIR: + break + directory = directory.parent + return dirs + + +EXAMPLE_DOC_DIRS = example_doc_dirs() + + +def slugify(value: str) -> str: + value = value.replace("README.md", "").strip("/") + value = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value) + value = value.replace("/", "-").replace("_", "-") + value = re.sub(r"[^A-Za-z0-9.-]+", "-", value) + value = re.sub(r"-+", "-", value) + return value.strip("-").lower() + + +def title_for_dir(directory: Path, root: Path, root_title: str) -> str: + if directory == root: + return root_title + return directory.name + + +def title_for(path: Path) -> str: + if path in PROJECT_DOC_BY_SOURCE: + return PROJECT_DOC_BY_SOURCE[path].title + if path == COMMON_MATH: + return "CommonMath" + if path == PHASOR_INPUT_FORMAT: + return "Input Format" + if is_example_readme(path): + return title_for_dir(path.parent, EXAMPLES_DIR, "Examples") + return path.parent.name + + +def is_readme_under(path: Path, root: Path) -> bool: + return path.name == "README.md" and root in path.parents + + +def model_parts_for_dir(model_dir: Path) -> list[str]: + return [slugify(part) for part in model_dir.relative_to(MODEL_DIR).parts] + + +def example_parts_for_dir(example_dir: Path) -> list[str]: + return [slugify(part) for part in example_dir.relative_to(EXAMPLES_DIR).parts] + + +def child_readme_dirs(model_dir: Path) -> list[Path]: + return sorted( + child + for child in model_dir.iterdir() + if child.is_dir() and (child / "README.md").exists() + ) + + +def example_child_dirs(directory: Path) -> list[Path]: + return sorted( + child + for child in EXAMPLE_DOC_DIRS + if child.parent == directory and child != directory + ) + + +def is_model_readme(path: Path) -> bool: + return is_readme_under(path, MODEL_DIR) + + +def is_example_readme(path: Path) -> bool: + return path in EXAMPLE_READMES + + +def is_model_container(path: Path) -> bool: + return is_model_readme(path) and bool(child_readme_dirs(path.parent)) + + +def is_example_container(path: Path) -> bool: + return is_example_readme(path) and bool(example_child_dirs(path.parent)) + + +def example_index_path_for_dir(directory: Path) -> Path: + return EXAMPLE_OUT_DIR.joinpath(*example_parts_for_dir(directory), "index.md") + + +def example_readme_output_path(path: Path) -> Path: + if is_example_container(path): + return EXAMPLE_OUT_DIR.joinpath(*example_parts_for_dir(path.parent), "overview.md") + return example_index_path_for_dir(path.parent) + + +def output_path_for(path: Path) -> Path: + if path in PROJECT_DOC_BY_SOURCE: + return PROJECT_DOC_BY_SOURCE[path].output + if path == COMMON_MATH: + return MODEL_OUT_DIR / "common-math.md" + if path == PHASOR_INPUT_FORMAT: + return MODEL_OUT_DIR / "phasor-dynamics" / "input-format.md" + if is_model_container(path): + return MODEL_OUT_DIR.joinpath(*model_parts_for_dir(path.parent), "overview.md") + if is_model_readme(path): + return MODEL_OUT_DIR.joinpath(*model_parts_for_dir(path.parent), "index.md") + if is_example_readme(path): + return example_readme_output_path(path) + raise ValueError(f"Unhandled documentation source: {path}") + + +def index_path_for(path: Path) -> Path: + if is_model_readme(path): + return MODEL_OUT_DIR.joinpath(*model_parts_for_dir(path.parent), "index.md") + if is_example_readme(path): + return example_index_path_for_dir(path.parent) + return output_path_for(path) + + +def is_url(target: str) -> bool: + return bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target)) + + +def split_anchor(target: str) -> tuple[str, str]: + path, sep, anchor = target.partition("#") + return path, f"{sep}{anchor}" if sep else "" + + +def resolve_local(source_dir: Path, target: str) -> Path: + return (source_dir / target).resolve() + + +def relpath_from_output(target: Path, current_output: Path) -> str: + return Path(os.path.relpath(target, current_output.parent)).as_posix() + + +def repo_source_url(path: Path) -> str: + relpath = path.relative_to(REPO_ROOT).as_posix() + return f"{GITHUB_TREE_URL}/{relpath}" + + +def fallback_repo_path(source_dir: Path, path_part: str) -> Path | None: + candidates: list[Path] = [] + if path_part.endswith("src/Model/PowerFlow/Gen/README.md"): + candidates.append(MODEL_DIR / "PowerFlow" / "README.md") + if path_part.startswith("src/Model/"): + candidates.append(REPO_ROOT / "GridKit" / path_part.removeprefix("src/")) + if path_part.startswith("Model/"): + candidates.append(REPO_ROOT / "GridKit" / path_part) + if "Model/" in path_part: + candidates.append( + REPO_ROOT / "GridKit" / "Model" / path_part.split("Model/", 1)[1] + ) + + source_parts = source_dir.relative_to(REPO_ROOT).parts + if len(source_parts) >= 2 and source_parts[0] == "application": + candidates.append(REPO_ROOT / "GridKit" / path_part.removeprefix("../../")) + + return next((candidate.resolve() for candidate in candidates if candidate.exists()), None) + + +def page_link(path: Path, anchor: str, current_output: Path) -> str | None: + if path == COMMON_MATH and anchor == "#anti-windup-indicator": + anchor = "#derived-functions" + + if path in PROJECT_DOC_BY_SOURCE or path == COMMON_MATH or path == PHASOR_INPUT_FORMAT: + return f"{relpath_from_output(output_path_for(path), current_output)}{anchor}" + if (is_model_readme(path) or is_example_readme(path)) and path.exists(): + return f"{relpath_from_output(output_path_for(path), current_output)}{anchor}" + return None + + +def rewrite_target(source_dir: Path, target: str, current_output: Path) -> str: + if not target or target.startswith("#") or is_url(target): + return target + + path_part, anchor = split_anchor(target) + resolved = resolve_local(source_dir, path_part) + fallback_path = None + if not resolved.exists(): + fallback_path = fallback_repo_path(source_dir, path_part) + if fallback_path is not None: + resolved = fallback_path + + generated_link = page_link(resolved, anchor, current_output) + if generated_link is not None: + return generated_link + + if resolved.exists() and not resolved.is_dir(): + return f"{relpath_from_output(resolved, current_output)}{anchor}" + if resolved.exists() and resolved.is_dir(): + readme = resolved / "README.md" + if readme.exists(): + generated_dir_link = page_link(readme, anchor, current_output) + if generated_dir_link is not None: + return generated_dir_link + return f"{repo_source_url(resolved)}{anchor}" + + try: + resolved.relative_to(REPO_ROOT) + except ValueError: + return target + + return f"{relpath_from_output(resolved, current_output)}{anchor}" + + +def rewrite_asset_target(source_dir: Path, target: str, current_output: Path) -> str: + if not target or target.startswith("#") or is_url(target): + return target + + path_part, anchor = split_anchor(target) + resolved = resolve_local(source_dir, path_part) + try: + path = relpath_from_output(resolved, current_output) + except ValueError: + return target + return f"{path}{anchor}" + + +def rewrite_markdown_links(text: str, source_dir: Path, current_output: Path) -> str: + def image_repl(match: re.Match[str]) -> str: + label, target = match.groups() + return f"![{label}]({rewrite_asset_target(source_dir, target, current_output)})" + + def link_repl(match: re.Match[str]) -> str: + label, target = match.groups() + return f"[{label}]({rewrite_target(source_dir, target, current_output)})" + + text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)\)", image_repl, text) + text = re.sub(r"(? str: + def repl(match: re.Match[str]) -> str: + prefix, target, suffix = match.groups() + return f"{prefix}{rewrite_asset_target(source_dir, target, current_output)}{suffix}" + + return re.sub(r"(]*\bsrc=[\"'])([^\"']+)([\"'])", repl, text) + + +def normalize_heading_levels(text: str) -> str: + levels = sorted( + {len(match.group(1)) for match in re.finditer(r"(?m)^(#{1,6})(\s+)", text)} + ) + mapping = {level: index + 2 for index, level in enumerate(levels)} + + def repl(match: re.Match[str]) -> str: + level = len(match.group(1)) + return f"{'#' * mapping.get(level, level)}{match.group(2)}" + + return re.sub(r"(?m)^(#{1,6})(\s+)", repl, text) + + +def comparable_title(value: str) -> str: + value = value.replace("™", "") + return re.sub(r"[^a-z0-9]+", "", value.lower()) + + +def strip_duplicate_top_heading(text: str, title: str) -> str: + match = re.match(r"\s*#\s+(.+?)\s*(?:\n+|$)", text) + if match and comparable_title(match.group(1)) == comparable_title(title): + return text[match.end() :] + return text + + +def normalize_comment_fences(text: str, source: Path) -> str: + if source != REPO_ROOT / "CONTRIBUTING.md": + return text + + def repl(match: re.Match[str]) -> str: + body = match.group(1) + lines = [line.strip() for line in body.splitlines() if line.strip()] + if lines and all(line.startswith(("/", "*")) for line in lines): + return f"```text\n{body}```" + return match.group(0) + + return re.sub(r"```c\+\+\n(.*?)```", repl, text, flags=re.DOTALL) + + +def normalize_markdown(text: str, source: Path, title: str) -> str: + text = strip_duplicate_top_heading(text, title) + text = normalize_comment_fences(text, source) + text = re.sub(r"(?m)^```\s*math\s*$", "```{math}", text) + text = re.sub(r"\$`([^`]+)`\$", r"$\1$", text) + text = normalize_heading_levels(text) + if source == COMMON_MATH: + text = re.sub( + r"(?m)^(#{2,6}\s+Derived Functions)", + r"(anti-windup-indicator)=\n\1", + text, + count=1, + ) + return text + + +def toctree_block(entries: list[str], *, hidden: bool = False, maxdepth: int = 2) -> str: + options = [f":maxdepth: {maxdepth}", ":titlesonly:"] + if hidden: + options.append(":hidden:") + + return ( + "```{toctree}\n" + + "\n".join(options) + + "\n\n" + + "\n".join(entries) + + "\n```\n" + ) + + +def markdown_link(title: str, path: Path, current_output: Path) -> str: + return f"[{title}]({relpath_from_output(path, current_output)})" + + +def tree_index_entries(source: Path, current_output: Path) -> list[str]: + entries = [relpath_from_output(output_path_for(source).with_suffix(""), current_output)] + if source == MODEL_DIR / "PhasorDynamics" / "README.md": + entries.append( + relpath_from_output( + output_path_for(PHASOR_INPUT_FORMAT).with_suffix(""), + current_output, + ) + ) + + entries.extend( + relpath_from_output( + index_path_for(child_dir / "README.md").with_suffix(""), + current_output, + ) + for child_dir in child_readme_dirs(source.parent) + ) + return entries + + +def overview_entries_for(source: Path, current_output: Path) -> list[str]: + entries = [ + markdown_link("Overview", output_path_for(source), current_output), + ] + if source == MODEL_DIR / "PhasorDynamics" / "README.md": + entries.append( + markdown_link("Input Format", output_path_for(PHASOR_INPUT_FORMAT), current_output) + ) + return entries + + +def model_contents_block(source: Path, current_output: Path) -> str: + child_dirs = child_readme_dirs(source.parent) + lines = ["## Pages", ""] + lines.extend(f"- {entry}" for entry in overview_entries_for(source, current_output)) + + if child_dirs: + lines.extend(["", "## Sections", ""]) + for child_dir in child_dirs: + child_readme = child_dir / "README.md" + child_link = markdown_link( + title_for(child_readme), + index_path_for(child_readme), + current_output, + ) + grandchild_links = [ + markdown_link( + title_for(grandchild / "README.md"), + index_path_for(grandchild / "README.md"), + current_output, + ) + for grandchild in child_readme_dirs(child_dir) + ] + if grandchild_links: + lines.append(f"- {child_link}: {', '.join(grandchild_links)}") + else: + lines.append(f"- {child_link}") + + return "\n".join(lines) + "\n" + + +def example_index_entries(directory: Path, current_output: Path) -> list[str]: + entries: list[str] = [] + readme = directory / "README.md" + if readme in EXAMPLE_READMES and example_child_dirs(directory): + entries.append(relpath_from_output(output_path_for(readme).with_suffix(""), current_output)) + + entries.extend( + relpath_from_output(example_index_path_for_dir(child).with_suffix(""), current_output) + for child in example_child_dirs(directory) + ) + return entries + + +def example_contents_block(directory: Path, current_output: Path) -> str: + readme = directory / "README.md" + child_dirs = example_child_dirs(directory) + lines: list[str] = [] + + if readme in EXAMPLE_READMES and child_dirs: + lines.extend( + [ + "## Pages", + "", + f"- {markdown_link('Overview', output_path_for(readme), current_output)}", + ] + ) + + if child_dirs: + if lines: + lines.append("") + lines.extend(["## Sections", ""]) + for child in child_dirs: + child_link = markdown_link( + title_for_dir(child, EXAMPLES_DIR, "Examples"), + example_index_path_for_dir(child), + current_output, + ) + grandchild_links = [ + markdown_link( + title_for_dir(grandchild, EXAMPLES_DIR, "Examples"), + example_index_path_for_dir(grandchild), + current_output, + ) + for grandchild in example_child_dirs(child) + ] + if grandchild_links: + lines.append(f"- {child_link}: {', '.join(grandchild_links)}") + else: + lines.append(f"- {child_link}") + + return "\n".join(lines) + "\n" + + +def generated_page_title(source: Path) -> str: + title = title_for(source) + if is_model_container(source) or is_example_container(source): + return "Overview" + return title + + +def generate_page(source: Path) -> None: + out = output_path_for(source) + source_dir = source.parent + title = generated_page_title(source) + body = source.read_text(encoding="utf-8") + body = normalize_markdown(body, source, title) + body = rewrite_markdown_links(body, source_dir, out) + body = rewrite_html_paths(body, source_dir, out) + + out.parent.mkdir(parents=True, exist_ok=True) + rel_source = source.relative_to(REPO_ROOT).as_posix() + out.write_text( + f"# {title}\n\n" + f"_Source: `{rel_source}`_\n\n" + f"{body.rstrip()}\n", + encoding="utf-8", + ) + + +def generate_model_index_page(source: Path) -> None: + if not is_model_container(source): + return + + out = index_path_for(source) + out.parent.mkdir(parents=True, exist_ok=True) + entries = tree_index_entries(source, out) + out.write_text( + f"# {title_for(source)}\n\n" + f"{toctree_block(entries, hidden=True)}\n" + f"{model_contents_block(source, out)}", + encoding="utf-8", + ) + + +def generate_example_index_page(directory: Path) -> None: + child_dirs = example_child_dirs(directory) + readme = directory / "README.md" + if not child_dirs and readme in EXAMPLE_READMES: + return + + out = example_index_path_for_dir(directory) + out.parent.mkdir(parents=True, exist_ok=True) + entries = example_index_entries(directory, out) + out.write_text( + f"# {title_for_dir(directory, EXAMPLES_DIR, 'Examples')}\n\n" + f"{toctree_block(entries, hidden=True)}\n" + f"{example_contents_block(directory, out)}", + encoding="utf-8", + ) + + +def main() -> None: + for generated_dir in (GENERAL_OUT_DIR, MODEL_OUT_DIR, EXAMPLE_OUT_DIR): + if generated_dir.exists(): + shutil.rmtree(generated_dir) + generated_dir.mkdir(parents=True, exist_ok=True) + + project_sources = [page.source for page in PROJECT_DOCS] + model_sources = [COMMON_MATH, PHASOR_INPUT_FORMAT] + model_sources.extend(sorted(MODEL_DIR.glob("**/README.md"))) + example_sources = sorted(EXAMPLE_READMES) + + for source in project_sources + model_sources + example_sources: + generate_page(source) + for source in model_sources: + generate_model_index_page(source) + for directory in sorted(EXAMPLE_DOC_DIRS): + generate_example_index_page(directory) + + +if __name__ == "__main__": + main() diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..ab487c268 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +# GridKit Documentation + +```{toctree} +:maxdepth: 4 +:titlesonly: +:hidden: + +generated/root-readme +project/index +examples/generated/index +models/index +api +``` + +## Sections + +- [GridKit Overview](generated/root-readme.md) +- [Project Documentation](project/index.md) +- [Examples](examples/generated/index.md) +- [Model Documentation](models/index.md) +- [API Reference](api.md) diff --git a/docs/models/index.md b/docs/models/index.md new file mode 100644 index 000000000..d2adaed7a --- /dev/null +++ b/docs/models/index.md @@ -0,0 +1,21 @@ +# Model Documentation + +```{toctree} +:maxdepth: 4 +:titlesonly: +:hidden: + +generated/common-math +generated/emt/index +generated/phasor-dynamics/index +generated/power-electronics/index +generated/power-flow/index +``` + +## Sections + +- [CommonMath](generated/common-math.md) +- [EMT](generated/emt/index.md) +- [PhasorDynamics](generated/phasor-dynamics/index.md) +- [PowerElectronics](generated/power-electronics/index.md) +- [PowerFlow](generated/power-flow/index.md) diff --git a/docs/project/index.md b/docs/project/index.md new file mode 100644 index 000000000..85263d229 --- /dev/null +++ b/docs/project/index.md @@ -0,0 +1,23 @@ +# Project Documentation + +```{toctree} +:maxdepth: 2 +:titlesonly: +:hidden: + +../generated/install +../generated/contributing +../generated/changelog +../generated/documentation-build +../generated/buildsystem +../generated/phasor-dynamics-application +``` + +## Sections + +- [Installation](../generated/install.md) +- [Contributing](../generated/contributing.md) +- [Changelog](../generated/changelog.md) +- [Documentation Build](../generated/documentation-build.md) +- [Buildsystem](../generated/buildsystem.md) +- [Phasor Dynamics Application](../generated/phasor-dynamics-application.md) diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..0f633b43e --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx +breathe +myst-parser +sphinx-rtd-theme diff --git a/examples/PhasorDynamics/Tiny/TwoBus/Gensal/README.md b/examples/PhasorDynamics/Tiny/TwoBus/Gensal/README.md index 475c30031..0d7cf94e2 100644 --- a/examples/PhasorDynamics/Tiny/TwoBus/Gensal/README.md +++ b/examples/PhasorDynamics/Tiny/TwoBus/Gensal/README.md @@ -9,8 +9,8 @@ monitored machine states are compared against `GENSAL.ref.csv`. ## Trajectory Comparison -![GENSAL validation trajectory](GENSAL.png) +![GENSAL validation trajectory](Gensal_validation.png) ## Error -![GENSAL validation error](GENSAL_ERROR.png) +![GENSAL validation error](Gensal_validation_error.png) From 19ed60ca93df2085f81e5470484fa75604744d4e Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 8 Jun 2026 00:25:17 -0500 Subject: [PATCH 2/3] Reorganize RTD structure --- .gitignore | 1 + CHANGELOG.md | 1 + docs/Doxyfile | 7 +- docs/api.md | 34 +++- docs/applications/index.md | 16 ++ .../phasor-dynamics/contingency-analysis.md | 14 ++ .../phasor-dynamics/dynamic-simulation.md | 12 ++ docs/applications/phasor-dynamics/index.md | 20 +++ docs/conf.py | 43 ++++- docs/{project => development}/index.md | 14 +- docs/generate_model_docs.py | 155 +++++++++--------- docs/index.md | 20 +-- docs/models/index.md | 2 +- docs/requirements.txt | 1 + 14 files changed, 234 insertions(+), 106 deletions(-) create mode 100644 docs/applications/index.md create mode 100644 docs/applications/phasor-dynamics/contingency-analysis.md create mode 100644 docs/applications/phasor-dynamics/dynamic-simulation.md create mode 100644 docs/applications/phasor-dynamics/index.md rename docs/{project => development}/index.md (64%) diff --git a/.gitignore b/.gitignore index 3860c865d..5689b87f9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ doxygen-docs/ docs/_build/ docs/api/generated/ +docs/api/reference/ docs/examples/generated/ docs/generated/ docs/models/generated/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 066786e09..612211975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Added 3, 10, 37, and 39 bus test cases. - Updated documentation. +- Added support for Read the Docs - Added JSON parsing. - Automatic differentiation with enzyme (w.r.t. internal and external variables). - Added PR and issue templates. diff --git a/docs/Doxyfile b/docs/Doxyfile index 98f1a96ec..8564e075e 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -1,14 +1,17 @@ PROJECT_NAME = "GridKit" OUTPUT_DIRECTORY = . -INPUT = ../GridKit ../README.md +INPUT = ../GridKit RECURSIVE = YES -USE_MDFILE_AS_MAINPAGE = ../README.md +EXCLUDE_PATTERNS = *.md +FULL_PATH_NAMES = YES +STRIP_FROM_PATH = .. GENERATE_HTML = NO GENERATE_LATEX = NO GENERATE_XML = YES XML_OUTPUT = xml +XML_PROGRAMLISTING = NO EXTRACT_ALL = YES QUIET = YES diff --git a/docs/api.md b/docs/api.md index 5218706cd..39b30249b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,5 +1,35 @@ # API Reference -```{doxygenindex} -:project: GridKit +```{toctree} +:maxdepth: 1 +:titlesonly: +:hidden: + +GridKit Core +Solvers +Phasor Dynamics +Power Electronics +Power Flow Data +Linear Algebra +Math Utilities +Model Utilities +Utilities +Testing ``` + +The API reference is organized by the primary GridKit namespaces. Lower-level +class, struct, enum, function, and file pages are generated by Exhale and linked +from these namespace pages. + +## Main Areas + +- [GridKit core](api/reference/namespace_GridKit.rst) +- [Solvers](api/reference/namespace_AnalysisManager.rst) +- [GridKit::PhasorDynamics](api/reference/namespace_GridKit__PhasorDynamics.rst) +- [GridKit::PowerElectronics](api/reference/namespace_GridKit__PowerElectronics.rst) +- [GridKit::PowerFlowData](api/reference/namespace_GridKit__PowerFlowData.rst) +- [GridKit::LinearAlgebra](api/reference/namespace_GridKit__LinearAlgebra.rst) +- [GridKit::Model](api/reference/namespace_GridKit__Model.rst) +- [GridKit::Math](api/reference/namespace_GridKit__Math.rst) +- [GridKit::Utilities](api/reference/namespace_GridKit__Utilities.rst) +- [GridKit::Testing](api/reference/namespace_GridKit__Testing.rst) diff --git a/docs/applications/index.md b/docs/applications/index.md new file mode 100644 index 000000000..a2344733b --- /dev/null +++ b/docs/applications/index.md @@ -0,0 +1,16 @@ +# Applications + +```{toctree} +:maxdepth: 4 +:titlesonly: +:hidden: + +phasor-dynamics/index +``` + +## Sections + +- [PhasorDynamics](phasor-dynamics/index.md) +- PowerElectronics: none +- PowerFlow: none +- EMT: none diff --git a/docs/applications/phasor-dynamics/contingency-analysis.md b/docs/applications/phasor-dynamics/contingency-analysis.md new file mode 100644 index 000000000..ea07ba64a --- /dev/null +++ b/docs/applications/phasor-dynamics/contingency-analysis.md @@ -0,0 +1,14 @@ +# ContingencyAnalysis + +`ContingencyAnalysis` runs a PhasorDynamics study for each configured bus fault. +When OpenMP is available, the fault studies run through the OpenMP path; otherwise +the application uses asynchronous tasks. + +## Inputs + +- [Application Input Format](../../generated/application-input-format.md) +- [System Model Input Format](../../models/generated/phasor-dynamics/input-format.md) + +## Source + +- `application/PhasorDynamics/ContingencyAnalysis.cpp` diff --git a/docs/applications/phasor-dynamics/dynamic-simulation.md b/docs/applications/phasor-dynamics/dynamic-simulation.md new file mode 100644 index 000000000..c7d7491e5 --- /dev/null +++ b/docs/applications/phasor-dynamics/dynamic-simulation.md @@ -0,0 +1,12 @@ +# DynamicSimulation + +`DynamicSimulation` runs one PhasorDynamics study from a solver JSON file. + +## Inputs + +- [Application Input Format](../../generated/application-input-format.md) +- [System Model Input Format](../../models/generated/phasor-dynamics/input-format.md) + +## Source + +- `application/PhasorDynamics/DynamicSimulation.cpp` diff --git a/docs/applications/phasor-dynamics/index.md b/docs/applications/phasor-dynamics/index.md new file mode 100644 index 000000000..ee28f46ef --- /dev/null +++ b/docs/applications/phasor-dynamics/index.md @@ -0,0 +1,20 @@ +# PhasorDynamics + +```{toctree} +:maxdepth: 2 +:titlesonly: +:hidden: + +dynamic-simulation +contingency-analysis +../../generated/application-input-format +``` + +## Applications + +- [DynamicSimulation](dynamic-simulation.md) +- [ContingencyAnalysis](contingency-analysis.md) + +## Shared Reference + +- [Application Input Format](../../generated/application-input-format.md) diff --git a/docs/conf.py b/docs/conf.py index fcd5342e8..e40f307a7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,15 +1,39 @@ from pathlib import Path +from exhale import utils as exhale_utils + project = "GridKit" author = "GridKit Developers" docs_dir = Path(__file__).parent.resolve() -extensions = ["breathe", "myst_parser", "sphinx.ext.graphviz"] +extensions = ["breathe", "exhale", "myst_parser", "sphinx.ext.graphviz"] breathe_projects = {"GridKit": str(docs_dir / "xml")} breathe_default_project = "GridKit" + +def exhale_specs(kind): + if kind in {"class", "struct"}: + return [":members:"] + return [] + + +exhale_args = { + "containmentFolder": "./api/reference", + "rootFileName": "EXCLUDE", + "doxygenStripFromPath": str(docs_dir.parent), + "createTreeView": False, + "customSpecificationsMapping": exhale_utils.makeCustomSpecificationsMapping( + exhale_specs + ), + "contentsDirectives": False, + "fullToctreeMaxDepth": 1, + "pageLevelConfigMeta": ":orphan:", +} + +primary_domain = "cpp" + html_theme = "sphinx_rtd_theme" html_extra_path = ["Figures"] html_theme_options = { @@ -30,4 +54,19 @@ ] myst_heading_anchors = 5 -exclude_patterns = ["_build", "xml", "README.md", "superpowers/**"] +exclude_patterns = [ + "_build", + "api/generated/**", + "xml", + "README.md", + "superpowers/**", +] + + +def rewrite_included_root_readme_links(app, relative_path, parent_docname, source): + if Path(str(relative_path)).name == "README.md": + source[0] = source[0].replace("](INSTALL.md", "](generated/install.md") + + +def setup(app): + app.connect("include-read", rewrite_included_root_readme_links) diff --git a/docs/project/index.md b/docs/development/index.md similarity index 64% rename from docs/project/index.md rename to docs/development/index.md index 85263d229..12d13f933 100644 --- a/docs/project/index.md +++ b/docs/development/index.md @@ -1,23 +1,19 @@ -# Project Documentation +# Development ```{toctree} :maxdepth: 2 :titlesonly: :hidden: -../generated/install ../generated/contributing -../generated/changelog -../generated/documentation-build ../generated/buildsystem -../generated/phasor-dynamics-application +../generated/documentation-build +../generated/changelog ``` ## Sections -- [Installation](../generated/install.md) - [Contributing](../generated/contributing.md) -- [Changelog](../generated/changelog.md) -- [Documentation Build](../generated/documentation-build.md) - [Buildsystem](../generated/buildsystem.md) -- [Phasor Dynamics Application](../generated/phasor-dynamics-application.md) +- [Documentation Build](../generated/documentation-build.md) +- [Changelog](../generated/changelog.md) diff --git a/docs/generate_model_docs.py b/docs/generate_model_docs.py index b3e34c9c4..e81462b52 100644 --- a/docs/generate_model_docs.py +++ b/docs/generate_model_docs.py @@ -26,12 +26,21 @@ class MarkdownPage: GENERAL_OUT_DIR = DOCS_DIR / "generated" MODEL_OUT_DIR = DOCS_DIR / "models" / "generated" EXAMPLE_OUT_DIR = DOCS_DIR / "examples" / "generated" +ROOT_INDEX = DOCS_DIR / "index.md" +ROOT_TOCTREE_ENTRIES = ( + "generated/install", + "applications/index", + "models/index", + "examples/generated/index", + "api", + "development/index", +) COMMON_MATH = REPO_ROOT / "GridKit" / "CommonMath.md" PHASOR_INPUT_FORMAT = MODEL_DIR / "PhasorDynamics" / "INPUT_FORMAT.md" PROJECT_DOCS = ( - MarkdownPage(REPO_ROOT / "README.md", GENERAL_OUT_DIR / "root-readme.md", "GridKit"), + MarkdownPage(REPO_ROOT / "README.md", ROOT_INDEX, "GridKit"), MarkdownPage(REPO_ROOT / "INSTALL.md", GENERAL_OUT_DIR / "install.md", "Installation"), MarkdownPage( REPO_ROOT / "CONTRIBUTING.md", @@ -55,8 +64,8 @@ class MarkdownPage: ), MarkdownPage( REPO_ROOT / "application" / "PhasorDynamics" / "README.md", - GENERAL_OUT_DIR / "phasor-dynamics-application.md", - "Phasor Dynamics Application", + GENERAL_OUT_DIR / "application-input-format.md", + "Application Input Format", ), ) @@ -155,8 +164,6 @@ def example_index_path_for_dir(directory: Path) -> Path: def example_readme_output_path(path: Path) -> Path: - if is_example_container(path): - return EXAMPLE_OUT_DIR.joinpath(*example_parts_for_dir(path.parent), "overview.md") return example_index_path_for_dir(path.parent) @@ -167,8 +174,6 @@ def output_path_for(path: Path) -> Path: return MODEL_OUT_DIR / "common-math.md" if path == PHASOR_INPUT_FORMAT: return MODEL_OUT_DIR / "phasor-dynamics" / "input-format.md" - if is_model_container(path): - return MODEL_OUT_DIR.joinpath(*model_parts_for_dir(path.parent), "overview.md") if is_model_readme(path): return MODEL_OUT_DIR.joinpath(*model_parts_for_dir(path.parent), "index.md") if is_example_readme(path): @@ -299,11 +304,43 @@ def link_repl(match: re.Match[str]) -> str: def rewrite_html_paths(text: str, source_dir: Path, current_output: Path) -> str: - def repl(match: re.Match[str]) -> str: - prefix, target, suffix = match.groups() - return f"{prefix}{rewrite_asset_target(source_dir, target, current_output)}{suffix}" + text = re.sub( + r"(?im)^\s*]*\balign=[\"']center[\"'][^>]*>\s*$", + "", + text, + ) + text = re.sub(r"(?im)^\s*\s*$", "", text) - return re.sub(r"(]*\bsrc=[\"'])([^\"']+)([\"'])", repl, text) + def html_attr(attrs: str, name: str) -> str | None: + match = re.search(rf"\b{name}\s*=\s*([\"'])(.*?)\1", attrs, re.IGNORECASE) + return match.group(2) if match else None + + def repl(match: re.Match[str]) -> str: + before, target, after = match.groups() + attrs = f"{before} {after}" + target = rewrite_asset_target(source_dir, target, current_output) + options = [] + + alt = html_attr(attrs, "alt") + if alt: + options.append(f":alt: {alt}") + + align = html_attr(attrs, "align") + if align in {"left", "center", "right"}: + options.append(f":align: {align}") + + option_block = "\n".join(options) + if option_block: + option_block += "\n" + + return f"\n```{{image}} {target}\n{option_block}```\n" + + return re.sub( + r"]*)\bsrc=[\"']([^\"']+)[\"']([^>]*)>", + repl, + text, + flags=re.IGNORECASE, + ) def normalize_heading_levels(text: str) -> str: @@ -380,7 +417,7 @@ def markdown_link(title: str, path: Path, current_output: Path) -> str: def tree_index_entries(source: Path, current_output: Path) -> list[str]: - entries = [relpath_from_output(output_path_for(source).with_suffix(""), current_output)] + entries = [] if source == MODEL_DIR / "PhasorDynamics" / "README.md": entries.append( relpath_from_output( @@ -399,52 +436,8 @@ def tree_index_entries(source: Path, current_output: Path) -> list[str]: return entries -def overview_entries_for(source: Path, current_output: Path) -> list[str]: - entries = [ - markdown_link("Overview", output_path_for(source), current_output), - ] - if source == MODEL_DIR / "PhasorDynamics" / "README.md": - entries.append( - markdown_link("Input Format", output_path_for(PHASOR_INPUT_FORMAT), current_output) - ) - return entries - - -def model_contents_block(source: Path, current_output: Path) -> str: - child_dirs = child_readme_dirs(source.parent) - lines = ["## Pages", ""] - lines.extend(f"- {entry}" for entry in overview_entries_for(source, current_output)) - - if child_dirs: - lines.extend(["", "## Sections", ""]) - for child_dir in child_dirs: - child_readme = child_dir / "README.md" - child_link = markdown_link( - title_for(child_readme), - index_path_for(child_readme), - current_output, - ) - grandchild_links = [ - markdown_link( - title_for(grandchild / "README.md"), - index_path_for(grandchild / "README.md"), - current_output, - ) - for grandchild in child_readme_dirs(child_dir) - ] - if grandchild_links: - lines.append(f"- {child_link}: {', '.join(grandchild_links)}") - else: - lines.append(f"- {child_link}") - - return "\n".join(lines) + "\n" - - def example_index_entries(directory: Path, current_output: Path) -> list[str]: entries: list[str] = [] - readme = directory / "README.md" - if readme in EXAMPLE_READMES and example_child_dirs(directory): - entries.append(relpath_from_output(output_path_for(readme).with_suffix(""), current_output)) entries.extend( relpath_from_output(example_index_path_for_dir(child).with_suffix(""), current_output) @@ -494,14 +487,34 @@ def example_contents_block(directory: Path, current_output: Path) -> str: def generated_page_title(source: Path) -> str: - title = title_for(source) - if is_model_container(source) or is_example_container(source): - return "Overview" - return title + return title_for(source) + + +def generated_page_toctree(source: Path, current_output: Path) -> str: + if source == REPO_ROOT / "README.md": + return toctree_block(list(ROOT_TOCTREE_ENTRIES), hidden=True, maxdepth=4) + if is_model_container(source): + entries = tree_index_entries(source, current_output) + return f"{toctree_block(entries, hidden=True)}\n" if entries else "" + if is_example_container(source): + entries = example_index_entries(source.parent, current_output) + return f"{toctree_block(entries, hidden=True)}\n" if entries else "" + return "" def generate_page(source: Path) -> None: out = output_path_for(source) + if source == REPO_ROOT / "README.md": + out.write_text( + "# GridKit\n\n" + f"{generated_page_toctree(source, out)}" + "```{include} ../README.md\n" + ":relative-images:\n" + "```\n", + encoding="utf-8", + ) + return + source_dir = source.parent title = generated_page_title(source) body = source.read_text(encoding="utf-8") @@ -513,31 +526,19 @@ def generate_page(source: Path) -> None: rel_source = source.relative_to(REPO_ROOT).as_posix() out.write_text( f"# {title}\n\n" + f"{generated_page_toctree(source, out)}" f"_Source: `{rel_source}`_\n\n" f"{body.rstrip()}\n", encoding="utf-8", ) -def generate_model_index_page(source: Path) -> None: - if not is_model_container(source): - return - - out = index_path_for(source) - out.parent.mkdir(parents=True, exist_ok=True) - entries = tree_index_entries(source, out) - out.write_text( - f"# {title_for(source)}\n\n" - f"{toctree_block(entries, hidden=True)}\n" - f"{model_contents_block(source, out)}", - encoding="utf-8", - ) - - def generate_example_index_page(directory: Path) -> None: child_dirs = example_child_dirs(directory) readme = directory / "README.md" - if not child_dirs and readme in EXAMPLE_READMES: + if readme in EXAMPLE_READMES: + return + if not child_dirs: return out = example_index_path_for_dir(directory) @@ -564,8 +565,6 @@ def main() -> None: for source in project_sources + model_sources + example_sources: generate_page(source) - for source in model_sources: - generate_model_index_page(source) for directory in sorted(EXAMPLE_DOC_DIRS): generate_example_index_page(directory) diff --git a/docs/index.md b/docs/index.md index ab487c268..bc4d0d2a8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,21 +1,17 @@ -# GridKit Documentation +# GridKit ```{toctree} :maxdepth: 4 :titlesonly: :hidden: -generated/root-readme -project/index -examples/generated/index +generated/install +applications/index models/index +examples/generated/index api +development/index +``` +```{include} ../README.md +:relative-images: ``` - -## Sections - -- [GridKit Overview](generated/root-readme.md) -- [Project Documentation](project/index.md) -- [Examples](examples/generated/index.md) -- [Model Documentation](models/index.md) -- [API Reference](api.md) diff --git a/docs/models/index.md b/docs/models/index.md index d2adaed7a..8ac0a888d 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -1,4 +1,4 @@ -# Model Documentation +# Models ```{toctree} :maxdepth: 4 diff --git a/docs/requirements.txt b/docs/requirements.txt index 0f633b43e..7f0c2b7ed 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ sphinx breathe +exhale myst-parser sphinx-rtd-theme From 79f578c13aef896878b2906b1f53f712175f4cc8 Mon Sep 17 00:00:00 2001 From: lukelowry Date: Mon, 8 Jun 2026 01:15:06 -0500 Subject: [PATCH 3/3] Trim uneeded docs --- docs/applications/index.md | 13 +- .../phasor-dynamics/contingency-analysis.md | 14 - .../phasor-dynamics/dynamic-simulation.md | 12 - docs/applications/phasor-dynamics/index.md | 20 - docs/conf.py | 9 - docs/generate_model_docs.py | 693 +++++++----------- docs/index.md | 4 +- 7 files changed, 261 insertions(+), 504 deletions(-) delete mode 100644 docs/applications/phasor-dynamics/contingency-analysis.md delete mode 100644 docs/applications/phasor-dynamics/dynamic-simulation.md delete mode 100644 docs/applications/phasor-dynamics/index.md diff --git a/docs/applications/index.md b/docs/applications/index.md index a2344733b..14211ecb9 100644 --- a/docs/applications/index.md +++ b/docs/applications/index.md @@ -1,16 +1,15 @@ # Applications ```{toctree} -:maxdepth: 4 +:maxdepth: 2 :titlesonly: :hidden: -phasor-dynamics/index +../generated/application-input-format ``` -## Sections +## PhasorDynamics -- [PhasorDynamics](phasor-dynamics/index.md) -- PowerElectronics: none -- PowerFlow: none -- EMT: none +- [Application Input Format](../generated/application-input-format.md) +- `application/PhasorDynamics/DynamicSimulation.cpp` +- `application/PhasorDynamics/ContingencyAnalysis.cpp` diff --git a/docs/applications/phasor-dynamics/contingency-analysis.md b/docs/applications/phasor-dynamics/contingency-analysis.md deleted file mode 100644 index ea07ba64a..000000000 --- a/docs/applications/phasor-dynamics/contingency-analysis.md +++ /dev/null @@ -1,14 +0,0 @@ -# ContingencyAnalysis - -`ContingencyAnalysis` runs a PhasorDynamics study for each configured bus fault. -When OpenMP is available, the fault studies run through the OpenMP path; otherwise -the application uses asynchronous tasks. - -## Inputs - -- [Application Input Format](../../generated/application-input-format.md) -- [System Model Input Format](../../models/generated/phasor-dynamics/input-format.md) - -## Source - -- `application/PhasorDynamics/ContingencyAnalysis.cpp` diff --git a/docs/applications/phasor-dynamics/dynamic-simulation.md b/docs/applications/phasor-dynamics/dynamic-simulation.md deleted file mode 100644 index c7d7491e5..000000000 --- a/docs/applications/phasor-dynamics/dynamic-simulation.md +++ /dev/null @@ -1,12 +0,0 @@ -# DynamicSimulation - -`DynamicSimulation` runs one PhasorDynamics study from a solver JSON file. - -## Inputs - -- [Application Input Format](../../generated/application-input-format.md) -- [System Model Input Format](../../models/generated/phasor-dynamics/input-format.md) - -## Source - -- `application/PhasorDynamics/DynamicSimulation.cpp` diff --git a/docs/applications/phasor-dynamics/index.md b/docs/applications/phasor-dynamics/index.md deleted file mode 100644 index ee28f46ef..000000000 --- a/docs/applications/phasor-dynamics/index.md +++ /dev/null @@ -1,20 +0,0 @@ -# PhasorDynamics - -```{toctree} -:maxdepth: 2 -:titlesonly: -:hidden: - -dynamic-simulation -contingency-analysis -../../generated/application-input-format -``` - -## Applications - -- [DynamicSimulation](dynamic-simulation.md) -- [ContingencyAnalysis](contingency-analysis.md) - -## Shared Reference - -- [Application Input Format](../../generated/application-input-format.md) diff --git a/docs/conf.py b/docs/conf.py index e40f307a7..3bf0b78d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,12 +61,3 @@ def exhale_specs(kind): "README.md", "superpowers/**", ] - - -def rewrite_included_root_readme_links(app, relative_path, parent_docname, source): - if Path(str(relative_path)).name == "README.md": - source[0] = source[0].replace("](INSTALL.md", "](generated/install.md") - - -def setup(app): - app.connect("include-read", rewrite_included_root_readme_links) diff --git a/docs/generate_model_docs.py b/docs/generate_model_docs.py index e81462b52..555155b92 100644 --- a/docs/generate_model_docs.py +++ b/docs/generate_model_docs.py @@ -1,196 +1,161 @@ #!/usr/bin/env python3 -"""Generate Sphinx pages from GridKit Markdown documentation.""" +"""Generate Sphinx pages from selected GridKit Markdown files.""" from __future__ import annotations import os import re import shutil -from dataclasses import dataclass +import subprocess from pathlib import Path -@dataclass(frozen=True) -class MarkdownPage: - source: Path - output: Path - title: str - - -REPO_ROOT = Path(__file__).resolve().parents[1] -DOCS_DIR = REPO_ROOT / "docs" -MODEL_DIR = REPO_ROOT / "GridKit" / "Model" -EXAMPLES_DIR = REPO_ROOT / "examples" -GITHUB_TREE_URL = "https://github.com/ORNL/GridKit/tree/develop" - -GENERAL_OUT_DIR = DOCS_DIR / "generated" -MODEL_OUT_DIR = DOCS_DIR / "models" / "generated" -EXAMPLE_OUT_DIR = DOCS_DIR / "examples" / "generated" -ROOT_INDEX = DOCS_DIR / "index.md" -ROOT_TOCTREE_ENTRIES = ( - "generated/install", - "applications/index", - "models/index", - "examples/generated/index", - "api", - "development/index", -) - -COMMON_MATH = REPO_ROOT / "GridKit" / "CommonMath.md" -PHASOR_INPUT_FORMAT = MODEL_DIR / "PhasorDynamics" / "INPUT_FORMAT.md" - -PROJECT_DOCS = ( - MarkdownPage(REPO_ROOT / "README.md", ROOT_INDEX, "GridKit"), - MarkdownPage(REPO_ROOT / "INSTALL.md", GENERAL_OUT_DIR / "install.md", "Installation"), - MarkdownPage( - REPO_ROOT / "CONTRIBUTING.md", - GENERAL_OUT_DIR / "contributing.md", - "Contributing", - ), - MarkdownPage( - REPO_ROOT / "CHANGELOG.md", - GENERAL_OUT_DIR / "changelog.md", - "Changelog", - ), - MarkdownPage( - DOCS_DIR / "README.md", - GENERAL_OUT_DIR / "documentation-build.md", - "Documentation Build", - ), - MarkdownPage( - REPO_ROOT / "buildsystem" / "README.md", - GENERAL_OUT_DIR / "buildsystem.md", - "Buildsystem", - ), - MarkdownPage( - REPO_ROOT / "application" / "PhasorDynamics" / "README.md", - GENERAL_OUT_DIR / "application-input-format.md", +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +MODEL = ROOT / "GridKit" / "Model" +EXAMPLES = ROOT / "examples" +GITHUB_REPO = "https://github.com/ORNL/GridKit" + +GENERATED = DOCS / "generated" +GENERATED_MODELS = DOCS / "models" / "generated" +GENERATED_EXAMPLES = DOCS / "examples" / "generated" + +COMMON_MATH = ROOT / "GridKit" / "CommonMath.md" +PHASOR_INPUT_FORMAT = MODEL / "PhasorDynamics" / "INPUT_FORMAT.md" + +PROJECT_PAGES = { + ROOT / "README.md": (GENERATED / "readme.md", "GridKit"), + ROOT / "INSTALL.md": (GENERATED / "install.md", "Installation"), + ROOT / "CONTRIBUTING.md": (GENERATED / "contributing.md", "Contributing"), + ROOT / "CHANGELOG.md": (GENERATED / "changelog.md", "Changelog"), + DOCS / "README.md": (GENERATED / "documentation-build.md", "Documentation Build"), + ROOT / "buildsystem" / "README.md": (GENERATED / "buildsystem.md", "Buildsystem"), + ROOT / "application" / "PhasorDynamics" / "README.md": ( + GENERATED / "application-input-format.md", "Application Input Format", ), -) - -PROJECT_DOC_BY_SOURCE = {page.source: page for page in PROJECT_DOCS} -EXAMPLE_READMES = frozenset(EXAMPLES_DIR.glob("**/README.md")) +} +MODEL_READMES = tuple(path.resolve() for path in sorted(MODEL.glob("**/README.md"))) +EXAMPLE_READMES = tuple(path.resolve() for path in sorted(EXAMPLES.glob("**/README.md"))) -def example_doc_dirs() -> set[Path]: - dirs = {EXAMPLES_DIR} - for readme in EXAMPLE_READMES: - directory = readme.parent - while directory != EXAMPLES_DIR.parent: - dirs.add(directory) - if directory == EXAMPLES_DIR: - break - directory = directory.parent - return dirs - -EXAMPLE_DOC_DIRS = example_doc_dirs() - - -def slugify(value: str) -> str: - value = value.replace("README.md", "").strip("/") - value = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value) - value = value.replace("/", "-").replace("_", "-") - value = re.sub(r"[^A-Za-z0-9.-]+", "-", value) - value = re.sub(r"-+", "-", value) - return value.strip("-").lower() - - -def title_for_dir(directory: Path, root: Path, root_title: str) -> str: - if directory == root: - return root_title - return directory.name - - -def title_for(path: Path) -> str: - if path in PROJECT_DOC_BY_SOURCE: - return PROJECT_DOC_BY_SOURCE[path].title - if path == COMMON_MATH: - return "CommonMath" - if path == PHASOR_INPUT_FORMAT: - return "Input Format" - if is_example_readme(path): - return title_for_dir(path.parent, EXAMPLES_DIR, "Examples") - return path.parent.name +def git_output(*args: str) -> str: + try: + return subprocess.check_output( + ["git", *args], + cwd=ROOT, + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError): + return "" -def is_readme_under(path: Path, root: Path) -> bool: - return path.name == "README.md" and root in path.parents +def source_ref() -> str: + if os.environ.get("READTHEDOCS_VERSION_TYPE") == "external": + if ref := os.environ.get("READTHEDOCS_GIT_COMMIT_HASH"): + return ref + if ref := os.environ.get("READTHEDOCS_GIT_IDENTIFIER"): + return ref + if ref := git_output("rev-parse", "--abbrev-ref", "HEAD"): + if ref != "HEAD": + return ref + return git_output("rev-parse", "HEAD") or "HEAD" -def model_parts_for_dir(model_dir: Path) -> list[str]: - return [slugify(part) for part in model_dir.relative_to(MODEL_DIR).parts] +SOURCE_REF = source_ref() -def example_parts_for_dir(example_dir: Path) -> list[str]: - return [slugify(part) for part in example_dir.relative_to(EXAMPLES_DIR).parts] +def slug(text: str) -> str: + text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", text) + text = re.sub(r"[^A-Za-z0-9.-]+", "-", text.replace("_", "-").replace("/", "-")) + return re.sub(r"-+", "-", text).strip("-").lower() -def child_readme_dirs(model_dir: Path) -> list[Path]: - return sorted( - child - for child in model_dir.iterdir() - if child.is_dir() and (child / "README.md").exists() - ) +def relative(target: Path, page: Path) -> str: + return Path(os.path.relpath(target, page.parent)).as_posix() -def example_child_dirs(directory: Path) -> list[Path]: - return sorted( - child - for child in EXAMPLE_DOC_DIRS - if child.parent == directory and child != directory - ) +def model_page(readme: Path) -> Path: + parts = [slug(part) for part in readme.parent.relative_to(MODEL).parts] + return GENERATED_MODELS.joinpath(*parts, "index.md") -def is_model_readme(path: Path) -> bool: - return is_readme_under(path, MODEL_DIR) +def example_page(directory: Path) -> Path: + parts = [slug(part) for part in directory.relative_to(EXAMPLES).parts] + return GENERATED_EXAMPLES.joinpath(*parts, "index.md") -def is_example_readme(path: Path) -> bool: - return path in EXAMPLE_READMES +SOURCE_TO_PAGE = {source.resolve(): output for source, (output, _) in PROJECT_PAGES.items()} +SOURCE_TO_PAGE[COMMON_MATH.resolve()] = GENERATED_MODELS / "common-math.md" +SOURCE_TO_PAGE[PHASOR_INPUT_FORMAT.resolve()] = ( + GENERATED_MODELS / "phasor-dynamics" / "input-format.md" +) +SOURCE_TO_PAGE.update({readme: model_page(readme) for readme in MODEL_READMES}) +SOURCE_TO_PAGE.update({readme: example_page(readme.parent) for readme in EXAMPLE_READMES}) -def is_model_container(path: Path) -> bool: - return is_model_readme(path) and bool(child_readme_dirs(path.parent)) +def example_dirs() -> set[Path]: + dirs = {EXAMPLES.resolve()} + for readme in EXAMPLE_READMES: + directory = readme.parent + while directory != EXAMPLES.parent: + dirs.add(directory.resolve()) + if directory == EXAMPLES: + break + directory = directory.parent + return dirs -def is_example_container(path: Path) -> bool: - return is_example_readme(path) and bool(example_child_dirs(path.parent)) +EXAMPLE_DOC_DIRS = example_dirs() -def example_index_path_for_dir(directory: Path) -> Path: - return EXAMPLE_OUT_DIR.joinpath(*example_parts_for_dir(directory), "index.md") +def title_for(source: Path) -> str: + if source in PROJECT_PAGES: + return PROJECT_PAGES[source][1] + if source == COMMON_MATH: + return "CommonMath" + if source == PHASOR_INPUT_FORMAT: + return "Input Format" + if source in EXAMPLE_READMES and source.parent == EXAMPLES: + return "Examples" + return source.parent.name -def example_readme_output_path(path: Path) -> Path: - return example_index_path_for_dir(path.parent) +def toctree(entries: list[str], maxdepth: int = 2) -> str: + return ( + "```{toctree}\n" + f":maxdepth: {maxdepth}\n" + ":titlesonly:\n:hidden:\n\n" + + "\n".join(entries) + + "\n```\n" + ) -def output_path_for(path: Path) -> Path: - if path in PROJECT_DOC_BY_SOURCE: - return PROJECT_DOC_BY_SOURCE[path].output - if path == COMMON_MATH: - return MODEL_OUT_DIR / "common-math.md" - if path == PHASOR_INPUT_FORMAT: - return MODEL_OUT_DIR / "phasor-dynamics" / "input-format.md" - if is_model_readme(path): - return MODEL_OUT_DIR.joinpath(*model_parts_for_dir(path.parent), "index.md") - if is_example_readme(path): - return example_readme_output_path(path) - raise ValueError(f"Unhandled documentation source: {path}") +def children_of(directory: Path, readmes: tuple[Path, ...]) -> list[Path]: + return sorted(readme.parent for readme in readmes if readme.parent.parent == directory) -def index_path_for(path: Path) -> Path: - if is_model_readme(path): - return MODEL_OUT_DIR.joinpath(*model_parts_for_dir(path.parent), "index.md") - if is_example_readme(path): - return example_index_path_for_dir(path.parent) - return output_path_for(path) +def example_children(directory: Path) -> list[Path]: + return sorted(path for path in EXAMPLE_DOC_DIRS if path.parent == directory and path != directory) -def is_url(target: str) -> bool: - return bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target)) +def generated_toctree(source: Path, page: Path) -> str: + entries = [] + if source in MODEL_READMES: + if source == MODEL / "PhasorDynamics" / "README.md": + entries.append(relative(SOURCE_TO_PAGE[PHASOR_INPUT_FORMAT.resolve()].with_suffix(""), page)) + entries += [ + relative(model_page(child / "README.md").with_suffix(""), page) + for child in children_of(source.parent, MODEL_READMES) + ] + elif source in EXAMPLE_READMES: + entries = [ + relative(example_page(child).with_suffix(""), page) + for child in example_children(source.parent) + ] + return f"{toctree(entries)}\n" if entries else "" def split_anchor(target: str) -> tuple[str, str]: @@ -198,196 +163,143 @@ def split_anchor(target: str) -> tuple[str, str]: return path, f"{sep}{anchor}" if sep else "" -def resolve_local(source_dir: Path, target: str) -> Path: - return (source_dir / target).resolve() - - -def relpath_from_output(target: Path, current_output: Path) -> str: - return Path(os.path.relpath(target, current_output.parent)).as_posix() - - -def repo_source_url(path: Path) -> str: - relpath = path.relative_to(REPO_ROOT).as_posix() - return f"{GITHUB_TREE_URL}/{relpath}" - - -def fallback_repo_path(source_dir: Path, path_part: str) -> Path | None: - candidates: list[Path] = [] - if path_part.endswith("src/Model/PowerFlow/Gen/README.md"): - candidates.append(MODEL_DIR / "PowerFlow" / "README.md") - if path_part.startswith("src/Model/"): - candidates.append(REPO_ROOT / "GridKit" / path_part.removeprefix("src/")) - if path_part.startswith("Model/"): - candidates.append(REPO_ROOT / "GridKit" / path_part) - if "Model/" in path_part: - candidates.append( - REPO_ROOT / "GridKit" / "Model" / path_part.split("Model/", 1)[1] - ) - - source_parts = source_dir.relative_to(REPO_ROOT).parts - if len(source_parts) >= 2 and source_parts[0] == "application": - candidates.append(REPO_ROOT / "GridKit" / path_part.removeprefix("../../")) - - return next((candidate.resolve() for candidate in candidates if candidate.exists()), None) +def is_external(target: str) -> bool: + return bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target)) -def page_link(path: Path, anchor: str, current_output: Path) -> str | None: - if path == COMMON_MATH and anchor == "#anti-windup-indicator": +def fallback(source_dir: Path, target: str) -> Path | None: + candidates = [] + if target.endswith("src/Model/PowerFlow/Gen/README.md"): + candidates.append(MODEL / "PowerFlow" / "README.md") + if target.startswith("src/Model/"): + candidates.append(ROOT / "GridKit" / target.removeprefix("src/")) + if target.startswith("Model/"): + candidates.append(ROOT / "GridKit" / target) + if "Model/" in target: + candidates.append(MODEL / target.split("Model/", 1)[1]) + if source_dir.relative_to(ROOT).parts[:1] == ("application",): + candidates.append(ROOT / "GridKit" / target.removeprefix("../../")) + return next((path.resolve() for path in candidates if path.exists()), None) + + +def page_link(path: Path, anchor: str, current_page: Path) -> str | None: + path = path.resolve() + if path == COMMON_MATH.resolve() and anchor == "#anti-windup-indicator": anchor = "#derived-functions" - - if path in PROJECT_DOC_BY_SOURCE or path == COMMON_MATH or path == PHASOR_INPUT_FORMAT: - return f"{relpath_from_output(output_path_for(path), current_output)}{anchor}" - if (is_model_readme(path) or is_example_readme(path)) and path.exists(): - return f"{relpath_from_output(output_path_for(path), current_output)}{anchor}" + if path in SOURCE_TO_PAGE: + return f"{relative(SOURCE_TO_PAGE[path], current_page)}{anchor}" + readme = (path / "README.md").resolve() if path.is_dir() else None + if readme in SOURCE_TO_PAGE: + return f"{relative(SOURCE_TO_PAGE[readme], current_page)}{anchor}" return None -def rewrite_target(source_dir: Path, target: str, current_output: Path) -> str: - if not target or target.startswith("#") or is_url(target): +def rewrite_link(source_dir: Path, target: str, current_page: Path) -> str: + if not target or target.startswith("#") or is_external(target): return target - - path_part, anchor = split_anchor(target) - resolved = resolve_local(source_dir, path_part) - fallback_path = None - if not resolved.exists(): - fallback_path = fallback_repo_path(source_dir, path_part) - if fallback_path is not None: - resolved = fallback_path - - generated_link = page_link(resolved, anchor, current_output) - if generated_link is not None: - return generated_link - - if resolved.exists() and not resolved.is_dir(): - return f"{relpath_from_output(resolved, current_output)}{anchor}" - if resolved.exists() and resolved.is_dir(): - readme = resolved / "README.md" - if readme.exists(): - generated_dir_link = page_link(readme, anchor, current_output) - if generated_dir_link is not None: - return generated_dir_link - return f"{repo_source_url(resolved)}{anchor}" - - try: - resolved.relative_to(REPO_ROOT) - except ValueError: - return target - - return f"{relpath_from_output(resolved, current_output)}{anchor}" - - -def rewrite_asset_target(source_dir: Path, target: str, current_output: Path) -> str: - if not target or target.startswith("#") or is_url(target): - return target - - path_part, anchor = split_anchor(target) - resolved = resolve_local(source_dir, path_part) - try: - path = relpath_from_output(resolved, current_output) - except ValueError: + path_text, anchor = split_anchor(target) + path = (source_dir / path_text).resolve() + if not path.exists(): + path = fallback(source_dir, path_text) or path + link = page_link(path, anchor, current_page) + if link: + return link + if path.exists() and path.is_dir(): + return f"{GITHUB_REPO}/tree/{SOURCE_REF}/{path.relative_to(ROOT).as_posix()}{anchor}" + if path.exists() or ROOT in path.parents: + return f"{relative(path, current_page)}{anchor}" + return target + + +def rewrite_asset(source_dir: Path, target: str, current_page: Path) -> str: + if not target or target.startswith("#") or is_external(target): return target - return f"{path}{anchor}" - - -def rewrite_markdown_links(text: str, source_dir: Path, current_output: Path) -> str: - def image_repl(match: re.Match[str]) -> str: - label, target = match.groups() - return f"![{label}]({rewrite_asset_target(source_dir, target, current_output)})" - - def link_repl(match: re.Match[str]) -> str: - label, target = match.groups() - return f"[{label}]({rewrite_target(source_dir, target, current_output)})" - - text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)\)", image_repl, text) - text = re.sub(r"(? str: +def rewrite_markdown_links(text: str, source_dir: Path, current_page: Path) -> str: text = re.sub( - r"(?im)^\s*]*\balign=[\"']center[\"'][^>]*>\s*$", - "", + r"!\[([^\]]*)\]\(([^)\s]+)\)", + lambda m: f"![{m.group(1)}]({rewrite_asset(source_dir, m.group(2), current_page)})", text, ) + return re.sub( + r"(? str: + text = re.sub(r"(?im)^\s*]*\balign=[\"']center[\"'][^>]*>\s*$", "", text) text = re.sub(r"(?im)^\s*\s*$", "", text) - def html_attr(attrs: str, name: str) -> str | None: + def attr(attrs: str, name: str) -> str | None: match = re.search(rf"\b{name}\s*=\s*([\"'])(.*?)\1", attrs, re.IGNORECASE) return match.group(2) if match else None - def repl(match: re.Match[str]) -> str: + def image(match: re.Match[str]) -> str: before, target, after = match.groups() attrs = f"{before} {after}" - target = rewrite_asset_target(source_dir, target, current_output) options = [] - - alt = html_attr(attrs, "alt") - if alt: + if alt := attr(attrs, "alt"): options.append(f":alt: {alt}") - - align = html_attr(attrs, "align") - if align in {"left", "center", "right"}: + if (align := attr(attrs, "align")) in {"left", "center", "right"}: options.append(f":align: {align}") - - option_block = "\n".join(options) - if option_block: - option_block += "\n" - - return f"\n```{{image}} {target}\n{option_block}```\n" + options_text = "\n".join(options) + if options_text: + options_text += "\n" + target = rewrite_asset(source_dir, target, current_page) + return f"\n```{{image}} {target}\n{options_text}```\n" return re.sub( r"]*)\bsrc=[\"']([^\"']+)[\"']([^>]*)>", - repl, + image, text, flags=re.IGNORECASE, ) -def normalize_heading_levels(text: str) -> str: - levels = sorted( - {len(match.group(1)) for match in re.finditer(r"(?m)^(#{1,6})(\s+)", text)} +def normalize_headings(text: str) -> str: + levels = sorted({len(m.group(1)) for m in re.finditer(r"(?m)^(#{1,6})(\s+)", text)}) + levels = {level: index + 2 for index, level in enumerate(levels)} + return re.sub( + r"(?m)^(#{1,6})(\s+)", + lambda m: f"{'#' * levels.get(len(m.group(1)), len(m.group(1)))}{m.group(2)}", + text, ) - mapping = {level: index + 2 for index, level in enumerate(levels)} - - def repl(match: re.Match[str]) -> str: - level = len(match.group(1)) - return f"{'#' * mapping.get(level, level)}{match.group(2)}" - - return re.sub(r"(?m)^(#{1,6})(\s+)", repl, text) -def comparable_title(value: str) -> str: - value = value.replace("™", "") - return re.sub(r"[^a-z0-9]+", "", value.lower()) +def strip_title(text: str, title: str) -> str: + def comparable(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "", value.replace("™", "").lower()) - -def strip_duplicate_top_heading(text: str, title: str) -> str: match = re.match(r"\s*#\s+(.+?)\s*(?:\n+|$)", text) - if match and comparable_title(match.group(1)) == comparable_title(title): + if match and comparable(match.group(1)) == comparable(title): return text[match.end() :] return text def normalize_comment_fences(text: str, source: Path) -> str: - if source != REPO_ROOT / "CONTRIBUTING.md": + if source != ROOT / "CONTRIBUTING.md": return text - def repl(match: re.Match[str]) -> str: - body = match.group(1) - lines = [line.strip() for line in body.splitlines() if line.strip()] + def fence(match: re.Match[str]) -> str: + lines = [line.strip() for line in match.group(1).splitlines() if line.strip()] if lines and all(line.startswith(("/", "*")) for line in lines): - return f"```text\n{body}```" + return f"```text\n{match.group(1)}```" return match.group(0) - return re.sub(r"```c\+\+\n(.*?)```", repl, text, flags=re.DOTALL) + return re.sub(r"```c\+\+\n(.*?)```", fence, text, flags=re.DOTALL) def normalize_markdown(text: str, source: Path, title: str) -> str: - text = strip_duplicate_top_heading(text, title) + text = strip_title(text, title) text = normalize_comment_fences(text, source) text = re.sub(r"(?m)^```\s*math\s*$", "```{math}", text) text = re.sub(r"\$`([^`]+)`\$", r"$\1$", text) - text = normalize_heading_levels(text) + text = normalize_headings(text) if source == COMMON_MATH: text = re.sub( r"(?m)^(#{2,6}\s+Derived Functions)", @@ -398,175 +310,76 @@ def normalize_markdown(text: str, source: Path, title: str) -> str: return text -def toctree_block(entries: list[str], *, hidden: bool = False, maxdepth: int = 2) -> str: - options = [f":maxdepth: {maxdepth}", ":titlesonly:"] - if hidden: - options.append(":hidden:") - - return ( - "```{toctree}\n" - + "\n".join(options) - + "\n\n" - + "\n".join(entries) - + "\n```\n" - ) - - -def markdown_link(title: str, path: Path, current_output: Path) -> str: - return f"[{title}]({relpath_from_output(path, current_output)})" - - -def tree_index_entries(source: Path, current_output: Path) -> list[str]: - entries = [] - if source == MODEL_DIR / "PhasorDynamics" / "README.md": - entries.append( - relpath_from_output( - output_path_for(PHASOR_INPUT_FORMAT).with_suffix(""), - current_output, - ) - ) - - entries.extend( - relpath_from_output( - index_path_for(child_dir / "README.md").with_suffix(""), - current_output, - ) - for child_dir in child_readme_dirs(source.parent) - ) - return entries - - -def example_index_entries(directory: Path, current_output: Path) -> list[str]: - entries: list[str] = [] - - entries.extend( - relpath_from_output(example_index_path_for_dir(child).with_suffix(""), current_output) - for child in example_child_dirs(directory) - ) - return entries +def write_page(source: Path) -> None: + source = source.resolve() + page = SOURCE_TO_PAGE[source] + page.parent.mkdir(parents=True, exist_ok=True) - -def example_contents_block(directory: Path, current_output: Path) -> str: - readme = directory / "README.md" - child_dirs = example_child_dirs(directory) - lines: list[str] = [] - - if readme in EXAMPLE_READMES and child_dirs: - lines.extend( - [ - "## Pages", - "", - f"- {markdown_link('Overview', output_path_for(readme), current_output)}", - ] - ) - - if child_dirs: - if lines: - lines.append("") - lines.extend(["## Sections", ""]) - for child in child_dirs: - child_link = markdown_link( - title_for_dir(child, EXAMPLES_DIR, "Examples"), - example_index_path_for_dir(child), - current_output, - ) - grandchild_links = [ - markdown_link( - title_for_dir(grandchild, EXAMPLES_DIR, "Examples"), - example_index_path_for_dir(grandchild), - current_output, - ) - for grandchild in example_child_dirs(child) - ] - if grandchild_links: - lines.append(f"- {child_link}: {', '.join(grandchild_links)}") - else: - lines.append(f"- {child_link}") - - return "\n".join(lines) + "\n" - - -def generated_page_title(source: Path) -> str: - return title_for(source) - - -def generated_page_toctree(source: Path, current_output: Path) -> str: - if source == REPO_ROOT / "README.md": - return toctree_block(list(ROOT_TOCTREE_ENTRIES), hidden=True, maxdepth=4) - if is_model_container(source): - entries = tree_index_entries(source, current_output) - return f"{toctree_block(entries, hidden=True)}\n" if entries else "" - if is_example_container(source): - entries = example_index_entries(source.parent, current_output) - return f"{toctree_block(entries, hidden=True)}\n" if entries else "" - return "" - - -def generate_page(source: Path) -> None: - out = output_path_for(source) - if source == REPO_ROOT / "README.md": - out.write_text( - "# GridKit\n\n" - f"{generated_page_toctree(source, out)}" - "```{include} ../README.md\n" - ":relative-images:\n" - "```\n", - encoding="utf-8", - ) + text = source.read_text(encoding="utf-8") + if source == (ROOT / "README.md").resolve(): + text = rewrite_markdown_links(text, source.parent, page) + page.write_text(text.rstrip() + "\n", encoding="utf-8") return - source_dir = source.parent - title = generated_page_title(source) - body = source.read_text(encoding="utf-8") - body = normalize_markdown(body, source, title) - body = rewrite_markdown_links(body, source_dir, out) - body = rewrite_html_paths(body, source_dir, out) - - out.parent.mkdir(parents=True, exist_ok=True) - rel_source = source.relative_to(REPO_ROOT).as_posix() - out.write_text( + title = title_for(source) + text = normalize_markdown(text, source, title) + text = rewrite_markdown_links(text, source.parent, page) + text = rewrite_html_images(text, source.parent, page) + source_name = source.relative_to(ROOT).as_posix() + page.write_text( f"# {title}\n\n" - f"{generated_page_toctree(source, out)}" - f"_Source: `{rel_source}`_\n\n" - f"{body.rstrip()}\n", + f"{generated_toctree(source, page)}" + f"_Source: `{source_name}`_\n\n" + f"{text.rstrip()}\n", encoding="utf-8", ) -def generate_example_index_page(directory: Path) -> None: - child_dirs = example_child_dirs(directory) - readme = directory / "README.md" - if readme in EXAMPLE_READMES: +def write_example_index(directory: Path) -> None: + if (directory / "README.md").resolve() in EXAMPLE_READMES: return - if not child_dirs: + children = example_children(directory) + if not children: return - out = example_index_path_for_dir(directory) - out.parent.mkdir(parents=True, exist_ok=True) - entries = example_index_entries(directory, out) - out.write_text( - f"# {title_for_dir(directory, EXAMPLES_DIR, 'Examples')}\n\n" - f"{toctree_block(entries, hidden=True)}\n" - f"{example_contents_block(directory, out)}", + page = example_page(directory) + page.parent.mkdir(parents=True, exist_ok=True) + entries = [relative(example_page(child).with_suffix(""), page) for child in children] + lines = ["## Sections", ""] + for child in children: + child_link = f"[{child.name}]({relative(example_page(child), page)})" + grandchildren = [ + f"[{grandchild.name}]({relative(example_page(grandchild), page)})" + for grandchild in example_children(child) + ] + lines.append(f"- {child_link}: {', '.join(grandchildren)}" if grandchildren else f"- {child_link}") + + page.write_text( + f"# {'Examples' if directory == EXAMPLES else directory.name}\n\n" + f"{toctree(entries)}\n" + + "\n".join(lines) + + "\n", encoding="utf-8", ) def main() -> None: - for generated_dir in (GENERAL_OUT_DIR, MODEL_OUT_DIR, EXAMPLE_OUT_DIR): - if generated_dir.exists(): - shutil.rmtree(generated_dir) - generated_dir.mkdir(parents=True, exist_ok=True) - - project_sources = [page.source for page in PROJECT_DOCS] - model_sources = [COMMON_MATH, PHASOR_INPUT_FORMAT] - model_sources.extend(sorted(MODEL_DIR.glob("**/README.md"))) - example_sources = sorted(EXAMPLE_READMES) - - for source in project_sources + model_sources + example_sources: - generate_page(source) + for directory in (GENERATED, GENERATED_MODELS, GENERATED_EXAMPLES): + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True, exist_ok=True) + + sources = [ + *PROJECT_PAGES, + COMMON_MATH, + PHASOR_INPUT_FORMAT, + *MODEL_READMES, + *EXAMPLE_READMES, + ] + for source in sources: + write_page(source) for directory in sorted(EXAMPLE_DOC_DIRS): - generate_example_index_page(directory) + write_example_index(directory) if __name__ == "__main__": diff --git a/docs/index.md b/docs/index.md index bc4d0d2a8..73347a2b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,6 @@ examples/generated/index api development/index ``` -```{include} ../README.md -:relative-images: +```{include} generated/readme.md +:relative-docs: generated/ ```