mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-11-03 11:48:13 +00:00
Refactored Sphinx Code
This commit is contained in:
0
sphinx/extensions/__init__.py
Normal file
0
sphinx/extensions/__init__.py
Normal file
49
sphinx/extensions/local_file_headings.py
Normal file
49
sphinx/extensions/local_file_headings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
from sphinx.util import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_MAX_NAV_DEPTH = 4
|
||||
|
||||
def add_local_file_headings(app, pagename, templatename, context, doctree):
|
||||
srcdir = app.srcdir
|
||||
directory = os.path.dirname(pagename)
|
||||
abs_dir = os.path.join(srcdir, directory)
|
||||
if not os.path.isdir(abs_dir):
|
||||
logger.warning(f"Directory {abs_dir} not found for page {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.md' in files_lower or 'index.rst' in files_lower:
|
||||
files = [f for f in files if f.lower() not in ['readme.md', 'readme.rst']]
|
||||
|
||||
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)
|
||||
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}
|
||||
71
sphinx/extensions/local_subfolders.py
Normal file
71
sphinx/extensions/local_subfolders.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import re
|
||||
from sphinx.util import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def collect_subfolder_tree(dir_path, base_url, current_depth, max_depth):
|
||||
"""
|
||||
Recursively collects navigation items from subdirectories.
|
||||
For each subfolder, it looks for a candidate file (prefer "index.rst" then "README.md")
|
||||
and extracts its first level‑1 heading as the title. If no candidate or heading is found,
|
||||
the folder name is used.
|
||||
Returns a list representing the subfolder tree.
|
||||
"""
|
||||
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):
|
||||
candidate = None
|
||||
for cand in ['index.rst', 'README.md']:
|
||||
candidate_path = os.path.join(full_path, cand)
|
||||
if os.path.isfile(candidate_path):
|
||||
candidate = candidate_path
|
||||
break
|
||||
if candidate:
|
||||
headings = extract_headings_from_file(candidate, max_level=MAX_HEADING_LEVEL)
|
||||
title = headings[0]['text'] if headings else item
|
||||
else:
|
||||
title = item
|
||||
link = os.path.join(base_url, item)
|
||||
entry = {
|
||||
'level': 1,
|
||||
'text': title,
|
||||
'link': link,
|
||||
'anchor': '',
|
||||
'priority': 0,
|
||||
'filename': item
|
||||
}
|
||||
if current_depth < max_depth:
|
||||
children = collect_subfolder_tree(full_path, os.path.join(base_url, item), current_depth + 1, max_depth)
|
||||
if children:
|
||||
entry['children'] = children
|
||||
items.append(entry)
|
||||
return items
|
||||
|
||||
def add_local_subfolders(app, pagename, templatename, context, doctree):
|
||||
"""
|
||||
Collects a tree of subfolder navigation items from the current directory.
|
||||
For each subfolder, the title is determined by scanning for a candidate file (prefer "index.rst" then "README.md")
|
||||
and extracting its first level‑1 heading, or using the folder name if none is found.
|
||||
The resulting tree is stored in context['local_subfolders'].
|
||||
"""
|
||||
srcdir = app.srcdir
|
||||
directory = os.path.dirname(pagename)
|
||||
abs_dir = os.path.join(srcdir, directory)
|
||||
if not os.path.isdir(abs_dir):
|
||||
logger.warning(f"Directory {abs_dir} not found for page {pagename}.")
|
||||
context['local_subfolders'] = []
|
||||
return
|
||||
|
||||
max_nav_depth = getattr(app.config, 'local_nav_max_depth', DEFAULT_MAX_NAV_DEPTH)
|
||||
subfolder_tree = collect_subfolder_tree(abs_dir, directory, current_depth=0, max_depth=max_nav_depth)
|
||||
sort_tree(subfolder_tree)
|
||||
context['local_subfolders'] = subfolder_tree
|
||||
|
||||
def setup(app):
|
||||
# Do not add the config value here to avoid conflicts.
|
||||
app.connect('html-page-context', add_local_subfolders)
|
||||
return {'version': '0.1', 'parallel_read_safe': True}
|
||||
53
sphinx/extensions/markdown_include.py
Normal file
53
sphinx/extensions/markdown_include.py
Normal file
@@ -0,0 +1,53 @@
|
||||
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 # Pfad zur Markdown-Datei
|
||||
optional_arguments = 0
|
||||
final_argument_whitespace = True
|
||||
has_content = False
|
||||
|
||||
def run(self):
|
||||
logger.info("markdown-include-Direktive wird ausgeführt")
|
||||
env = self.state.document.settings.env
|
||||
# Ermittle den absoluten Pfad der Datei.
|
||||
rel_filename, filename = env.relfn2path(self.arguments[0])
|
||||
logger.info("Markdown-Datei: %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 den Markdown-Content mit MystParser.
|
||||
parser = MystParser()
|
||||
from docutils.frontend import OptionParser
|
||||
from docutils.utils import new_document
|
||||
settings = OptionParser(components=(MystParser,)).get_default_values()
|
||||
# Hänge die Sphinx-Umgebung an die Einstellungen an, damit myst_parser funktioniert.
|
||||
settings.env = self.state.document.settings.env
|
||||
doc = new_document(filename, settings=settings)
|
||||
parser.parse(markdown_content, doc)
|
||||
logger.info("Markdown-Parsing erfolgreich abgeschlossen")
|
||||
return doc.children
|
||||
|
||||
def setup(app):
|
||||
app.add_directive("markdown-include", MarkdownIncludeDirective)
|
||||
return {'version': '0.1', 'parallel_read_safe': True}
|
||||
84
sphinx/extensions/nav_utils.py
Normal file
84
sphinx/extensions/nav_utils.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
|
||||
DEFAULT_MAX_NAV_DEPTH = 4
|
||||
MAX_HEADING_LEVEL = 2
|
||||
|
||||
def natural_sort_key(text):
|
||||
"""
|
||||
Generate a key for natural (human-friendly) sorting,
|
||||
taking numeric parts into account.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Extract headings from a file.
|
||||
For Markdown (.md) files, looks for lines starting with '#' (up to max_level).
|
||||
For reStructuredText (.rst) files, looks for a line immediately followed by an underline.
|
||||
Returns a list of dictionaries with keys: 'level', 'text', and 'anchor' (if applicable).
|
||||
"""
|
||||
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
|
||||
match = re.match(r'^(#{1,})\s+(.*)$', 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()
|
||||
# For reST, the anchor is left empty (can be generated later if needed)
|
||||
headings.append({'level': level, 'text': heading_text, 'anchor': ''})
|
||||
except Exception as e:
|
||||
print(f"Warning: Error reading {filepath}: {e}")
|
||||
return headings
|
||||
|
||||
def group_headings(headings):
|
||||
"""
|
||||
Convert a flat list of headings into a tree structure based on their level.
|
||||
Each heading gets a 'children' list.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Sort a tree of navigation items, first by a 'priority' value (lower comes first)
|
||||
and then by a natural sort key based on the 'filename' field (or the 'text' field if no filename is provided).
|
||||
This ensures that 'index' and 'readme' (priority 0) always appear at the top.
|
||||
"""
|
||||
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
sphinx/extensions/roles_overview.py
Normal file
116
sphinx/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}
|
||||
Reference in New Issue
Block a user