mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-29 15:06:26 +02:00
Moved sphinx files
This commit is contained in:
2
docs/.gitignore
vendored
Normal file
2
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
assets/img/*
|
||||
generated/*
|
41
docs/Makefile
Normal file
41
docs/Makefile
Normal file
@@ -0,0 +1,41 @@
|
||||
# Minimal Makefile for Sphinx documentation
|
||||
#
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment
|
||||
SPHINXOPTS ?= -c .
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SPHINX_SOURCE_DIR ?= ../
|
||||
SPHINX_BUILD_DIR ?= ./generated
|
||||
SPHINX_APIDOC_BUILD_DIR = $(SPHINX_SOURCE_DIR)modules
|
||||
|
||||
.PHONY: help install copy-images apidoc remove-apidoc html Makefile
|
||||
|
||||
# Copy images before running any Sphinx command (except for help)
|
||||
copy-images:
|
||||
@echo "Copying images from ../assets/img/ to ./assets/img/..."
|
||||
cp -vr ../assets/img/* ./assets/img/
|
||||
|
||||
# Generate reStructuredText files from Python modules using sphinx-apidoc
|
||||
apidoc:
|
||||
@echo "Running sphinx-apidoc..."
|
||||
sphinx-apidoc -f -o $(SPHINX_APIDOC_BUILD_DIR) $(SPHINX_SOURCE_DIR)
|
||||
|
||||
remove-apidoc:
|
||||
@echo "Removing sphinx-apidoc files..."
|
||||
- rm -rv $(SPHINX_APIDOC_BUILD_DIR)
|
||||
|
||||
# "help" target does not copy images
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SPHINX_SOURCE_DIR)" "$(SPHINX_BUILD_DIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
# HTML target depends on apidoc so that sphinx-apidoc runs first
|
||||
html: copy-images
|
||||
@$(SPHINXBUILD) -M html "$(SPHINX_SOURCE_DIR)" "$(SPHINX_BUILD_DIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
clean: remove-apidoc
|
||||
@$(SPHINXBUILD) -M html "$(SPHINX_SOURCE_DIR)" "$(SPHINX_BUILD_DIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SPHINX_SOURCE_DIR)" "$(SPHINX_BUILD_DIR)" $(SPHINXOPTS) $(O)
|
56
docs/README.md
Normal file
56
docs/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Documentation
|
||||
|
||||
CyMaIS uses [Sphinx](https://www.sphinx-doc.org/) to automatically generate its documentation and leverages the [Awesome Sphinx Theme](https://sphinxawesome.xyz/) for a sleek and responsive design. Enjoy a seamless, visually engaging experience 🚀✨.
|
||||
|
||||
## For Users
|
||||
|
||||
You can access the documentation [here](https://docs.cymais.cloud/) 🔗. Browse the latest updates and guides to get started.
|
||||
|
||||
## For Administrators
|
||||
|
||||
### Setup
|
||||
|
||||
#### On Localhost
|
||||
|
||||
To generate the documentation locally, run the following command:
|
||||
|
||||
```bash
|
||||
pkgmgr shell cymais -c "make refresh"
|
||||
```
|
||||
|
||||
This command performs the following steps:
|
||||
- **Copy Images:** Before building, it copies the necessary image assets from `../assets/img/` to `./assets/img/` using the `copy-images` target.
|
||||
- **Generate API Documentation:** It executes `sphinx-apidoc` (via the `apidoc` target) to automatically generate reStructuredText files for all Python modules. These files are stored under a designated directory (e.g., `modules`), ensuring that every Python file is included in the documentation.
|
||||
- **Build HTML Documentation:** Finally, it builds the HTML documentation using `sphinx-build` (triggered by the `html` target).
|
||||
|
||||
Once complete, you can view the documentation at the output location (e.g., [templates/html/index.html](templates/html/index.html)) 👀💻.
|
||||
|
||||
#### On Server
|
||||
|
||||
The same commands can be used on the server to ensure that documentation is always up to date. Make sure the server environment is properly configured with the necessary Python packages and assets.
|
||||
|
||||
### Additional Commands
|
||||
|
||||
- **`make copy-images`:**
|
||||
Copies image files from the assets directory into the local documentation directory. This ensures that all required images are available for the generated documentation.
|
||||
|
||||
- **`make apidoc`:**
|
||||
Runs `sphinx-apidoc` to scan all Python files in the source directory and generate corresponding reStructuredText files. This automates the inclusion of all Python modules into the Sphinx documentation.
|
||||
|
||||
- **`make html`:**
|
||||
This target depends on the `apidoc` target. It first generates the API documentation and then builds the HTML documentation using `sphinx-build`. This is the standard target to produce the final, viewable documentation.
|
||||
|
||||
- **`make refresh`:**
|
||||
A custom target (typically defined as a combination of cleaning the previous build and then running `make html`) that ensures the documentation is regenerated from scratch with the latest changes.
|
||||
|
||||
### Debug
|
||||
|
||||
To debug and produce a log file, execute:
|
||||
|
||||
```bash
|
||||
pkgmgr shell cymais -c "make refresh SPHINXOPTS='-v -c .' 2>&1 | tee debug.log"
|
||||
```
|
||||
|
||||
This command increases the verbosity of the Sphinx build process and redirects all output to `debug.log`, which is useful for troubleshooting any issues during the documentation build.
|
||||
|
||||
```
|
102
docs/assets/js/current-nav.js
Normal file
102
docs/assets/js/current-nav.js
Normal file
@@ -0,0 +1,102 @@
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
// Initialization: wait for window load and then trigger current nav detection.
|
||||
window.addEventListener("load", function() {
|
||||
console.log("Window loaded, initializing current nav...");
|
||||
initCurrentNav();
|
||||
});
|
||||
|
||||
// Re-trigger when the hash changes.
|
||||
window.addEventListener("hashchange", function() {
|
||||
console.log("Hash changed, reinitializing current nav...");
|
||||
initCurrentNav();
|
||||
});
|
||||
|
||||
function initCurrentNav() {
|
||||
// If Alpine.js is available and provides nextTick, use it.
|
||||
if (window.Alpine && typeof window.Alpine.nextTick === 'function') {
|
||||
window.Alpine.nextTick(processNav);
|
||||
} else {
|
||||
processNav();
|
||||
}
|
||||
}
|
||||
|
||||
function processNav() {
|
||||
var currentHash = window.location.hash;
|
||||
console.log("initCurrentNav: Current hash:", currentHash);
|
||||
if (!currentHash) return;
|
||||
|
||||
// Select all internal links within the .current-index container.
|
||||
var links = document.querySelectorAll('.current-index a.reference.internal');
|
||||
links.forEach(function(link) {
|
||||
var href = link.getAttribute("href");
|
||||
console.log("initCurrentNav: Checking link:", href);
|
||||
// If the link is hash-only (e.g. "#setup-guide")
|
||||
if (href && href.trim().startsWith("#")) {
|
||||
if (href.trim() === currentHash.trim()) {
|
||||
console.log("initCurrentNav: Match found for hash-only link:", href);
|
||||
document.querySelectorAll('.current-index a.reference.internal.current').forEach(function(link) {
|
||||
link.classList.remove("current");
|
||||
});
|
||||
link.classList.add("current");
|
||||
markAsCurrent(link);
|
||||
}
|
||||
}
|
||||
// Otherwise, if the link includes a file and a hash, compare the hash part.
|
||||
else if (href && href.indexOf('#') !== -1) {
|
||||
var parts = href.split('#');
|
||||
var linkHash = "#" + parts[1].trim();
|
||||
console.log("initCurrentNav: Extracted link hash:", linkHash);
|
||||
if (linkHash === currentHash.trim()) {
|
||||
console.log("initCurrentNav: Match found for link with file and hash:", href);
|
||||
markAsCurrent(link);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log("initCurrentNav: No match for link:", href);
|
||||
}
|
||||
});
|
||||
|
||||
// After processing links, open submenus only for those li elements marked as current.
|
||||
openCurrentSubmenus();
|
||||
}
|
||||
|
||||
// Mark the link's parent li and all its ancestor li elements as current.
|
||||
function markAsCurrent(link) {
|
||||
var li = link.closest("li");
|
||||
if (!li) {
|
||||
console.log("markAsCurrent: No parent li found for link:", link);
|
||||
return;
|
||||
}
|
||||
li.classList.add("current");
|
||||
console.log("markAsCurrent: Marked li as current:", li);
|
||||
// If Alpine.js is used, set its "expanded" property to true.
|
||||
if (li.__x && li.__x.$data) {
|
||||
li.__x.$data.expanded = true;
|
||||
console.log("markAsCurrent: Set Alpine expanded on li:", li);
|
||||
}
|
||||
// Propagate upward: mark all ancestor li elements as current.
|
||||
var parentLi = li.parentElement.closest("li");
|
||||
while (parentLi) {
|
||||
parentLi.classList.add("current");
|
||||
if (parentLi.__x && parentLi.__x.$data) {
|
||||
parentLi.__x.$data.expanded = true;
|
||||
}
|
||||
console.log("markAsCurrent: Propagated current to ancestor li:", parentLi);
|
||||
parentLi = parentLi.parentElement.closest("li");
|
||||
}
|
||||
}
|
||||
|
||||
// Open immediate submenu elements (the direct children with x-show) of li.current.
|
||||
function openCurrentSubmenus() {
|
||||
document.querySelectorAll('.current-index li.current').forEach(function(li) {
|
||||
// Only target immediate child elements that have x-show.
|
||||
li.querySelectorAll(":scope > [x-show]").forEach(function(elem) {
|
||||
if (elem.style.display === "none" || elem.style.display === "") {
|
||||
elem.style.display = "block";
|
||||
console.log("openCurrentSubmenus: Opened submenu element:", elem);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
window.initCurrentNav = initCurrentNav;
|
||||
});
|
103
docs/conf.py
Normal file
103
docs/conf.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# Check if a verbose flag is present in the command line arguments.
|
||||
if any(arg in sys.argv for arg in ["-v", "--verbose"]):
|
||||
logging_level = logging.DEBUG
|
||||
else:
|
||||
logging_level = logging.INFO
|
||||
|
||||
logging.basicConfig(level=logging_level)
|
||||
|
||||
import os
|
||||
sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
project = 'CyMaIS - Cyber Master Infrastructure Solution'
|
||||
copyright = '2025, Kevin Veen-Birkenbach'
|
||||
author = 'Kevin Veen-Birkenbach'
|
||||
|
||||
# Highlighting for Jinja
|
||||
from sphinx.highlighting import lexers
|
||||
from pygments.lexers.templates import DjangoLexer
|
||||
|
||||
lexers['jinja'] = DjangoLexer()
|
||||
lexers['j2'] = DjangoLexer()
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
templates_path = ['templates']
|
||||
exclude_patterns = ['docs', 'venv', 'venv/**']
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
html_theme = 'sphinxawesome_theme'
|
||||
html_static_path = ['assets']
|
||||
|
||||
html_sidebars = {
|
||||
'**': [
|
||||
'logo.html',
|
||||
'structure.html', # Include your custom template
|
||||
]
|
||||
}
|
||||
|
||||
cymais_logo = "assets/img/logo.png"
|
||||
html_favicon = "assets/img/favicon.ico"
|
||||
|
||||
html_theme_options = {
|
||||
"show_prev_next": False,
|
||||
"logo_light": cymais_logo,
|
||||
"logo_dark": cymais_logo,
|
||||
}
|
||||
|
||||
source_suffix = {
|
||||
'.rst': 'restructuredtext',
|
||||
'.md': 'markdown',
|
||||
}
|
||||
|
||||
sys.path.insert(0, os.path.abspath('./extensions'))
|
||||
extensions = [
|
||||
'sphinx.ext.autosummary',
|
||||
'sphinx.ext.autodoc',
|
||||
'myst_parser',
|
||||
'extensions.local_file_headings',
|
||||
'extensions.local_subfolders',
|
||||
'extensions.roles_overview',
|
||||
'extensions.markdown_include',
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.napoleon', # Optional, wenn Sie Google- oder NumPy-Dokstrings verwenden
|
||||
|
||||
]
|
||||
|
||||
autosummary_generate = True
|
||||
|
||||
myst_enable_extensions = [
|
||||
"colon_fence",
|
||||
]
|
||||
|
||||
import logging
|
||||
from docutils import nodes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def replace_assets_in_doctree(app, doctree, docname):
|
||||
# Replace asset references in image nodes
|
||||
for node in doctree.traverse(nodes.image):
|
||||
if "assets/" in node['uri']:
|
||||
new_uri = node['uri'].replace("assets/", "_static/")
|
||||
node['uri'] = new_uri
|
||||
logger.info("Replaced image URI in {}: {}".format(docname, new_uri))
|
||||
|
||||
# Replace asset references in raw HTML nodes
|
||||
for node in doctree.traverse(nodes.raw):
|
||||
if node.get('format') == 'html' and "assets/" in node.astext():
|
||||
new_text = node.astext().replace("assets/", "_static/")
|
||||
node.children = [nodes.raw('', new_text, format='html')]
|
||||
logger.info("Replaced raw HTML assets in {}.".format(docname))
|
||||
|
||||
def setup(app):
|
||||
app.connect("doctree-resolved", replace_assets_in_doctree)
|
||||
|
||||
python_domain = app.registry.domains.get('py')
|
||||
if python_domain is not None:
|
||||
directive = python_domain.directives.get('currentmodule')
|
||||
if directive is not None:
|
||||
directive.optional_arguments = 10
|
||||
return {'version': '1.0', 'parallel_read_safe': True}
|
0
docs/extensions/__init__.py
Normal file
0
docs/extensions/__init__.py
Normal file
61
docs/extensions/local_file_headings.py
Normal file
61
docs/extensions/local_file_headings.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import sys
|
||||
import logging as std_logging # Use the standard logging module
|
||||
from sphinx.util import logging # Sphinx logging is used elsewhere if needed
|
||||
from docutils.parsers.rst import Directive
|
||||
from .nav_utils import natural_sort_key, extract_headings_from_file, group_headings, sort_tree, MAX_HEADING_LEVEL, DEFAULT_MAX_NAV_DEPTH
|
||||
|
||||
# Set up our logger based on command-line args.
|
||||
logger = std_logging.getLogger(__name__)
|
||||
if any(arg in sys.argv for arg in ["-v", "--verbose"]):
|
||||
logger.setLevel(std_logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(std_logging.INFO)
|
||||
|
||||
DEFAULT_MAX_NAV_DEPTH = 4
|
||||
|
||||
def add_local_file_headings(app, pagename, templatename, context, doctree):
|
||||
logger.debug("add_local_file_headings called with pagename: %s", pagename)
|
||||
|
||||
srcdir = app.srcdir
|
||||
directory = os.path.dirname(pagename)
|
||||
abs_dir = os.path.join(srcdir, directory)
|
||||
if not os.path.isdir(abs_dir):
|
||||
logger.warning("Directory %s not found for page %s.", abs_dir, pagename)
|
||||
context['local_md_headings'] = []
|
||||
return
|
||||
|
||||
# Get only files with .md or .rst extensions.
|
||||
files = [f for f in os.listdir(abs_dir) if f.endswith('.md') or f.endswith('.rst')]
|
||||
# If an index file is present, remove any readme files (case-insensitive).
|
||||
files_lower = [f.lower() for f in files]
|
||||
if 'index.rst' in files_lower:
|
||||
files = [f for f in files if f.lower() not in ['readme.md']]
|
||||
|
||||
file_items = []
|
||||
for file in files:
|
||||
filepath = os.path.join(abs_dir, file)
|
||||
headings = extract_headings_from_file(filepath, max_level=MAX_HEADING_LEVEL)
|
||||
basename, _ = os.path.splitext(file)
|
||||
# Set priority: index gets priority 0, otherwise 1.
|
||||
priority = 0 if basename.lower() == 'index' else 1
|
||||
for heading in headings:
|
||||
file_link = os.path.join(directory, basename)
|
||||
file_items.append({
|
||||
'level': heading['level'],
|
||||
'text': heading['text'],
|
||||
'link': file_link,
|
||||
'anchor': heading['anchor'],
|
||||
'priority': priority,
|
||||
'filename': basename
|
||||
})
|
||||
tree = group_headings(file_items)
|
||||
sort_tree(tree)
|
||||
|
||||
logger.debug("Generated tree: %s", tree)
|
||||
context['local_md_headings'] = tree
|
||||
|
||||
def setup(app):
|
||||
app.add_config_value('local_nav_max_depth', DEFAULT_MAX_NAV_DEPTH, 'env')
|
||||
app.connect('html-page-context', add_local_file_headings)
|
||||
return {'version': '0.1', 'parallel_read_safe': True}
|
130
docs/extensions/local_subfolders.py
Normal file
130
docs/extensions/local_subfolders.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
from sphinx.util import logging
|
||||
from .nav_utils import extract_headings_from_file, MAX_HEADING_LEVEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CANDIDATES = ['index.rst', 'readme.md', 'main.rst']
|
||||
|
||||
def collect_folder_tree(dir_path, base_url):
|
||||
"""
|
||||
Recursively collects the folder tree starting from the given directory.
|
||||
|
||||
For each folder:
|
||||
- Hidden folders (names starting with a dot) are skipped.
|
||||
- A folder is processed only if it contains one of the representative files:
|
||||
index.rst, index.md, readme.md, or readme.rst.
|
||||
- The first heading of the representative file is used as the folder title.
|
||||
- The representative file is not listed as a file in the folder.
|
||||
- All other Markdown and reStructuredText files are listed without sub-headings,
|
||||
using their first heading as the file title.
|
||||
"""
|
||||
# Skip hidden directories
|
||||
if os.path.basename(dir_path).startswith('.'):
|
||||
return None
|
||||
|
||||
# List all files in the current directory with .md or .rst extension
|
||||
files = [f for f in os.listdir(dir_path)
|
||||
if os.path.isfile(os.path.join(dir_path, f))
|
||||
and (f.endswith('.md') or f.endswith('.rst'))]
|
||||
|
||||
# Find representative file for folder title using index or readme
|
||||
rep_file = None
|
||||
for candidate in CANDIDATES:
|
||||
for f in files:
|
||||
if f.lower() == candidate:
|
||||
rep_file = f
|
||||
break
|
||||
if rep_file:
|
||||
break
|
||||
|
||||
# Skip this folder if no representative file exists
|
||||
if not rep_file:
|
||||
return None
|
||||
|
||||
rep_path = os.path.join(dir_path, rep_file)
|
||||
headings = extract_headings_from_file(rep_path, max_level=MAX_HEADING_LEVEL)
|
||||
folder_title = headings[0]['text'] if headings else os.path.basename(dir_path)
|
||||
folder_link = os.path.join(base_url, os.path.splitext(rep_file)[0])
|
||||
|
||||
# Remove the representative file from the list to avoid duplication,
|
||||
# and filter out any additional "readme.md" or "index.rst" files.
|
||||
files.remove(rep_file)
|
||||
files = [f for f in files if f.lower() not in CANDIDATES]
|
||||
|
||||
# Process the remaining files in the current directory
|
||||
file_items = []
|
||||
for file in sorted(files, key=lambda s: s.lower()):
|
||||
file_path = os.path.join(dir_path, file)
|
||||
file_headings = extract_headings_from_file(file_path, max_level=MAX_HEADING_LEVEL)
|
||||
file_title = file_headings[0]['text'] if file_headings else file
|
||||
file_base = os.path.splitext(file)[0]
|
||||
file_link = os.path.join(base_url, file_base)
|
||||
file_items.append({
|
||||
'level': 1,
|
||||
'text': file_title,
|
||||
'link': file_link,
|
||||
'anchor': '',
|
||||
'priority': 1,
|
||||
'filename': file
|
||||
})
|
||||
|
||||
# Process subdirectories (ignoring hidden ones)
|
||||
dir_items = []
|
||||
for item in sorted(os.listdir(dir_path), key=lambda s: s.lower()):
|
||||
full_path = os.path.join(dir_path, item)
|
||||
if os.path.isdir(full_path) and not item.startswith('.'):
|
||||
subtree = collect_folder_tree(full_path, os.path.join(base_url, item))
|
||||
if subtree:
|
||||
dir_items.append(subtree)
|
||||
|
||||
# Combine files and subdirectories as children of the current folder
|
||||
children = file_items + dir_items
|
||||
|
||||
return {
|
||||
'text': folder_title,
|
||||
'link': folder_link,
|
||||
'children': children,
|
||||
'filename': os.path.basename(dir_path)
|
||||
}
|
||||
|
||||
def mark_current(node, active):
|
||||
"""
|
||||
Recursively mark nodes as current if the active page (pagename)
|
||||
matches the node's link or is a descendant of it.
|
||||
|
||||
The function sets node['current'] = True if:
|
||||
- The node's link matches the active page exactly, or
|
||||
- The active page begins with the node's link plus a separator (indicating a child).
|
||||
Additionally, if any child node is current, the parent is marked as current.
|
||||
"""
|
||||
is_current = False
|
||||
node_link = node.get('link', '').rstrip('/')
|
||||
active = active.rstrip('/')
|
||||
if node_link and (active == node_link or active.startswith(node_link + '/')):
|
||||
is_current = True
|
||||
|
||||
# Recurse into children if they exist
|
||||
children = node.get('children', [])
|
||||
for child in children:
|
||||
if mark_current(child, active):
|
||||
is_current = True
|
||||
|
||||
node['current'] = is_current
|
||||
return is_current
|
||||
|
||||
def add_local_subfolders(app, pagename, templatename, context, doctree):
|
||||
"""
|
||||
Sets the 'local_subfolders' context variable with the entire folder tree
|
||||
starting from app.srcdir, and marks the tree with the 'current' flag up
|
||||
to the active page.
|
||||
"""
|
||||
root_dir = app.srcdir
|
||||
folder_tree = collect_folder_tree(root_dir, '')
|
||||
if folder_tree:
|
||||
mark_current(folder_tree, pagename)
|
||||
context['local_subfolders'] = [folder_tree] if folder_tree else []
|
||||
|
||||
def setup(app):
|
||||
app.connect('html-page-context', add_local_subfolders)
|
||||
return {'version': '0.1', 'parallel_read_safe': True}
|
80
docs/extensions/markdown_include.py
Normal file
80
docs/extensions/markdown_include.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import os
|
||||
from docutils import nodes
|
||||
from docutils.parsers.rst import Directive
|
||||
from sphinx.util import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from myst_parser.parsers.sphinx_ import MystParser
|
||||
|
||||
class MarkdownIncludeDirective(Directive):
|
||||
required_arguments = 1 # Path to the Markdown file
|
||||
optional_arguments = 0
|
||||
final_argument_whitespace = True
|
||||
has_content = False
|
||||
|
||||
def run(self):
|
||||
logger.info("Executing markdown-include directive")
|
||||
env = self.state.document.settings.env
|
||||
# Determine the absolute path of the file.
|
||||
rel_filename, filename = env.relfn2path(self.arguments[0])
|
||||
logger.info("Markdown file: %s", filename)
|
||||
if not os.path.exists(filename):
|
||||
error = self.state_machine.reporter.error(
|
||||
f'File not found: {filename}',
|
||||
nodes.literal_block(self.block_text, self.block_text),
|
||||
line=self.lineno)
|
||||
return [error]
|
||||
|
||||
try:
|
||||
with open(filename, 'r', encoding='utf-8') as f:
|
||||
markdown_content = f.read()
|
||||
except Exception as e:
|
||||
error = self.state_machine.reporter.error(
|
||||
f'Error reading file {filename}: {e}',
|
||||
nodes.literal_block(self.block_text, self.block_text),
|
||||
line=self.lineno)
|
||||
return [error]
|
||||
|
||||
# Parse the Markdown content with MystParser.
|
||||
parser = MystParser()
|
||||
from docutils.frontend import OptionParser
|
||||
from docutils.utils import new_document
|
||||
settings = OptionParser(components=(MystParser,)).get_default_values()
|
||||
# Attach the Sphinx environment to the settings so that myst_parser works.
|
||||
settings.env = self.state.document.settings.env
|
||||
doc = new_document(filename, settings=settings)
|
||||
parser.parse(markdown_content, doc)
|
||||
logger.info("Markdown parsing completed successfully")
|
||||
|
||||
# Remove the first header (title) if it exists.
|
||||
if doc.children:
|
||||
first_section = doc.children[0]
|
||||
if isinstance(first_section, nodes.section) and first_section.children:
|
||||
first_child = first_section.children[0]
|
||||
if isinstance(first_child, nodes.title):
|
||||
# If there are additional children, remove the title node.
|
||||
if len(first_section.children) > 1:
|
||||
first_section.pop(0)
|
||||
logger.info("Removed first header from Markdown content")
|
||||
else:
|
||||
# If it's the only child, clear its content instead.
|
||||
first_child.clear()
|
||||
logger.info("Cleared text of first header from Markdown content")
|
||||
|
||||
# Unwrap the first section if it no longer has a title.
|
||||
if isinstance(first_section, nodes.section):
|
||||
has_title = any(isinstance(child, nodes.title) and child.astext().strip()
|
||||
for child in first_section.children)
|
||||
if not has_title:
|
||||
# Remove the section wrapper so that its content does not create a TOC entry.
|
||||
unwrapped = list(first_section.children)
|
||||
# Replace the first section with its children.
|
||||
doc.children = unwrapped + doc.children[1:]
|
||||
logger.info("Unwrapped first section to avoid a TOC entry")
|
||||
|
||||
return doc.children
|
||||
|
||||
def setup(app):
|
||||
app.add_directive("markdown-include", MarkdownIncludeDirective)
|
||||
return {'version': '0.1', 'parallel_read_safe': True}
|
78
docs/extensions/nav_utils.py
Normal file
78
docs/extensions/nav_utils.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
|
||||
DEFAULT_MAX_NAV_DEPTH = 4
|
||||
MAX_HEADING_LEVEL = 0 # This can be overridden in your configuration
|
||||
|
||||
def natural_sort_key(text):
|
||||
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)]
|
||||
|
||||
def extract_headings_from_file(filepath, max_level=MAX_HEADING_LEVEL):
|
||||
# If max_level is 0, set it to a very high value to effectively iterate infinitely
|
||||
if max_level == 0:
|
||||
max_level = 9999
|
||||
|
||||
headings = []
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
if ext == '.md':
|
||||
in_code_block = False
|
||||
for line in f:
|
||||
if line.strip().startswith("```"):
|
||||
in_code_block = not in_code_block
|
||||
continue
|
||||
if in_code_block:
|
||||
continue
|
||||
# Assuming markdown headings are defined with '#' characters
|
||||
match = re.match(r'^(#{1,})(.*?)$', line)
|
||||
if match:
|
||||
level = len(match.group(1))
|
||||
if level <= max_level:
|
||||
heading_text = match.group(2).strip()
|
||||
anchor = re.sub(r'\s+', '-', heading_text.lower())
|
||||
anchor = re.sub(r'[^a-z0-9\-]', '', anchor)
|
||||
headings.append({'level': level, 'text': heading_text, 'anchor': anchor})
|
||||
elif ext == '.rst':
|
||||
lines = f.readlines()
|
||||
for i in range(len(lines) - 1):
|
||||
text_line = lines[i].rstrip("\n")
|
||||
underline = lines[i+1].rstrip("\n")
|
||||
if len(underline) >= 3 and re.fullmatch(r'[-=~\^\+"\'`]+', underline):
|
||||
level = 1
|
||||
heading_text = text_line.strip()
|
||||
headings.append({'level': level, 'text': heading_text, 'anchor': ''})
|
||||
except Exception as e:
|
||||
print(f"Warning: Error reading {filepath}: {e}")
|
||||
if not headings:
|
||||
base = os.path.basename(filepath).lower()
|
||||
if base == 'index.rst':
|
||||
folder = os.path.dirname(filepath)
|
||||
readme_path = os.path.join(folder, 'README.md')
|
||||
if os.path.isfile(readme_path):
|
||||
try:
|
||||
headings = extract_headings_from_file(readme_path, max_level)
|
||||
except Exception as e:
|
||||
print(f"Warning: Error reading fallback README.md in {folder}: {e}")
|
||||
return headings
|
||||
|
||||
def group_headings(headings):
|
||||
tree = []
|
||||
stack = []
|
||||
for heading in headings:
|
||||
heading['children'] = []
|
||||
while stack and stack[-1]['level'] >= heading['level']:
|
||||
stack.pop()
|
||||
if stack:
|
||||
stack[-1]['children'].append(heading)
|
||||
else:
|
||||
tree.append(heading)
|
||||
stack.append(heading)
|
||||
return tree
|
||||
|
||||
def sort_tree(tree):
|
||||
tree.sort(key=lambda x: (x.get('priority', 1), natural_sort_key(x.get('filename', x['text']))))
|
||||
for item in tree:
|
||||
if item.get('children'):
|
||||
sort_tree(item['children'])
|
116
docs/extensions/roles_overview.py
Normal file
116
docs/extensions/roles_overview.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import os
|
||||
import glob
|
||||
import re
|
||||
import yaml
|
||||
from docutils import nodes
|
||||
from sphinx.util import logging
|
||||
from docutils.parsers.rst import Directive
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RolesOverviewDirective(Directive):
|
||||
"""
|
||||
A directive to embed a roles overview as reStructuredText.
|
||||
|
||||
It scans the roles directory (i.e. every folder under "roles") for a "meta/main.yml" file,
|
||||
reads the role’s galaxy tags and description, and outputs an overview grouped by each tag.
|
||||
For each role, it attempts to extract a level‑1 heading from its README.md as the title.
|
||||
If no title is found, the role folder name is used.
|
||||
The title is rendered as a clickable link to the role's README.md.
|
||||
"""
|
||||
has_content = False
|
||||
|
||||
def run(self):
|
||||
env = self.state.document.settings.env
|
||||
srcdir = env.srcdir
|
||||
roles_dir = os.path.join(srcdir, 'roles')
|
||||
if not os.path.isdir(roles_dir):
|
||||
logger.warning(f"Roles directory not found: {roles_dir}")
|
||||
error_node = self.state.document.reporter.error(
|
||||
"Roles directory not found.", line=self.lineno)
|
||||
return [error_node]
|
||||
|
||||
# Gather role entries grouped by tag.
|
||||
categories = {}
|
||||
for role_path in glob.glob(os.path.join(roles_dir, '*')):
|
||||
if os.path.isdir(role_path):
|
||||
meta_path = os.path.join(role_path, 'meta', 'main.yml')
|
||||
if os.path.exists(meta_path):
|
||||
try:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading YAML file {meta_path}: {e}")
|
||||
continue
|
||||
|
||||
role_name = os.path.basename(role_path)
|
||||
# Determine title from README.md if present.
|
||||
readme_path = os.path.join(role_path, 'README.md')
|
||||
title = role_name
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
match = re.match(r'^#\s+(.*)$', line)
|
||||
if match:
|
||||
title = match.group(1).strip()
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading README.md for {role_name}: {e}")
|
||||
|
||||
galaxy_info = data.get('galaxy_info', {})
|
||||
tags = galaxy_info.get('galaxy_tags', [])
|
||||
if not tags:
|
||||
tags = ['uncategorized']
|
||||
role_description = galaxy_info.get('description', '')
|
||||
role_entry = {
|
||||
'name': role_name,
|
||||
'title': title,
|
||||
'description': role_description,
|
||||
'link': f'roles/{role_name}/README.md',
|
||||
'tags': tags,
|
||||
}
|
||||
for tag in tags:
|
||||
categories.setdefault(tag, []).append(role_entry)
|
||||
else:
|
||||
logger.warning(f"meta/main.yml not found for role {role_path}")
|
||||
|
||||
# Sort categories and roles alphabetically.
|
||||
sorted_categories = sorted(categories.items(), key=lambda x: x[0].lower())
|
||||
for tag, roles in sorted_categories:
|
||||
roles.sort(key=lambda r: r['name'].lower())
|
||||
|
||||
# Build document structure.
|
||||
container = nodes.container()
|
||||
|
||||
# For each category, create a section to serve as a large category heading.
|
||||
for tag, roles in sorted_categories:
|
||||
# Create a section for the category.
|
||||
cat_id = nodes.make_id(tag)
|
||||
category_section = nodes.section(ids=[cat_id])
|
||||
category_title = nodes.title(text=tag)
|
||||
category_section += category_title
|
||||
|
||||
# For each role within the category, create a subsection.
|
||||
for role in roles:
|
||||
role_section_id = nodes.make_id(role['title'])
|
||||
role_section = nodes.section(ids=[role_section_id])
|
||||
# Create a title node with a clickable reference.
|
||||
role_title = nodes.title()
|
||||
reference = nodes.reference(text=role['title'], refuri=role['link'])
|
||||
role_title += reference
|
||||
role_section += role_title
|
||||
|
||||
if role['description']:
|
||||
para = nodes.paragraph(text=role['description'])
|
||||
role_section += para
|
||||
|
||||
category_section += role_section
|
||||
|
||||
container += category_section
|
||||
|
||||
return [container]
|
||||
|
||||
def setup(app):
|
||||
app.add_directive("roles-overview", RolesOverviewDirective)
|
||||
return {'version': '0.1', 'parallel_read_safe': True}
|
5
docs/requirements.txt
Normal file
5
docs/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
myst-parser
|
||||
sphinx
|
||||
sphinxawesome-theme
|
||||
docutils
|
||||
sphinxcontrib-jinja
|
5
docs/templates/logo.html
vendored
Normal file
5
docs/templates/logo.html
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="sidebar-logo" style="text-align: center; margin-bottom: 1em;">
|
||||
<img src="{{ pathto("_static/img/logo.png", 1) }}" alt="Logo" style="max-width: 100%;">
|
||||
</div>
|
||||
|
||||
|
65
docs/templates/structure.html
vendored
Normal file
65
docs/templates/structure.html
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
{% macro render_headings(headings, level=1) %}
|
||||
<ul class="toctree-l{{ level }}" style="list-style: none; padding-left: 0; overflow-x: auto; white-space: nowrap;">
|
||||
{% for item in headings %}
|
||||
<li class="toctree-l{{ level }}{% if item.current %} current{% endif %}"
|
||||
{% if item.children %}
|
||||
x-data="{ expanded: {{ 'true' if item.current else 'false' }} }"
|
||||
{% endif %}
|
||||
style="white-space: nowrap;">
|
||||
<div class="menu-item" style="display: inline-flex; align-items: center; justify-content: space-between; width: 100%; white-space: nowrap;">
|
||||
<!-- Link and file open section -->
|
||||
<div style="display: inline-flex; align-items: center; white-space: nowrap;">
|
||||
<a class="reference internal{% if item.children %} expandable{% endif %}{% if item.current and not item.children %} current{% endif %}"
|
||||
href="{{ pathto(item.link).replace('#', '') }}{% if item.anchor %}#{{ item.anchor }}{% endif %}"
|
||||
style="text-decoration: none; white-space: nowrap;">
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</div>
|
||||
<!-- Expand-Toggle Button -->
|
||||
{% if item.children %}
|
||||
<button @click.prevent.stop="expanded = !expanded" type="button" class="toggle-button"
|
||||
style="background: none; border: none; padding: 0; margin-left: auto;">
|
||||
<span x-show="!expanded">
|
||||
<svg fill="currentColor" height="18px" stroke="none" viewBox="0 0 24 24" width="18px"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span x-show="expanded">▼</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.children %}
|
||||
<div x-show="expanded">
|
||||
{{ render_headings(item.children, level+1) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
{% if local_md_headings or local_subfolders %}
|
||||
<div class="local-md-headings">
|
||||
{% if local_md_headings %}
|
||||
<div class="current-index" x-data x-init="typeof initCurrentNav === 'function' && initCurrentNav()">
|
||||
<p class="caption" role="heading">
|
||||
<span class="caption-text">Current Index</span>
|
||||
</p>
|
||||
{{ render_headings(local_md_headings) }}
|
||||
<br />
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if local_subfolders %}
|
||||
<div class="full-index">
|
||||
<p class="caption" role="heading">
|
||||
<span class="caption-text">Full Index</span>
|
||||
</p>
|
||||
{{ render_headings(local_subfolders) }}
|
||||
<br />
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<script src="{{ pathto('_static/js/current-nav.js', 1) }}"></script>
|
||||
|
Reference in New Issue
Block a user