Implemented subfolders in navigation

This commit is contained in:
Kevin Veen-Birkenbach 2025-03-16 22:14:34 +01:00
parent 871dc04fc9
commit 74edb197de
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
2 changed files with 67 additions and 18 deletions

View File

@ -1,11 +1,10 @@
# License Agreement # CyMaIS NonCommercial License (CNCL)
## CyMaIS NonCommercial License (CNCL)
### Definitions ## Definitions
- **"Software":** Refers to *"[CyMaIS - Cyber Master Infrastructure Solution](https://cymais.cloud/)"* and its associated source code. - **"Software":** Refers to *"[CyMaIS - Cyber Master Infrastructure Solution](https://cymais.cloud/)"* and its associated source code.
- **"Commercial Use":** Any use of the Software intended for direct or indirect financial gain, including but not limited to sales, rentals, or provision of services. - **"Commercial Use":** Any use of the Software intended for direct or indirect financial gain, including but not limited to sales, rentals, or provision of services.
### Provisions ## Provisions
1. **Attribution of the Original Licensor:** In any distribution or publication of the Software or derivative works, the original licensor, *Kevin Veen-Birkenbach, Email: [license@veen.world](mailto:license@veen.world), Website: [https://www.veen.world/](https://www.veen.world/)* must be explicitly named. 1. **Attribution of the Original Licensor:** In any distribution or publication of the Software or derivative works, the original licensor, *Kevin Veen-Birkenbach, Email: [license@veen.world](mailto:license@veen.world), Website: [https://www.veen.world/](https://www.veen.world/)* must be explicitly named.
@ -24,5 +23,5 @@
7. **Ownership of Rights:** All rights, including copyright, trademark, and other forms of intellectual property related to the Software, belong exclusively to Kevin Veen-Birkenbach. 7. **Ownership of Rights:** All rights, including copyright, trademark, and other forms of intellectual property related to the Software, belong exclusively to Kevin Veen-Birkenbach.
### Consent ## Consent
By using, modifying, or distributing the Software, you agree to these terms. By using, modifying, or distributing the Software, you agree to these terms.

View File

@ -4,8 +4,9 @@ from sphinx.util import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Set the maximum heading level to include (e.g., include headings up to H3 for Markdown) # Set the maximum heading level for Markdown headings
MAX_HEADING_LEVEL = 3 MAX_HEADING_LEVEL = 3
DEFAULT_MAX_NAV_DEPTH = 2 # Default maximum navigation depth; configurable via conf.py
def natural_sort_key(text): def natural_sort_key(text):
""" """
@ -16,8 +17,9 @@ def natural_sort_key(text):
def extract_headings_from_file(filepath, max_level=MAX_HEADING_LEVEL): def extract_headings_from_file(filepath, max_level=MAX_HEADING_LEVEL):
""" """
Extract headings from a file. For Markdown files, look for lines starting with '#' (up to max_level). Extract headings from a file.
For reStructuredText files, look for a line that is immediately followed by an underline made of punctuation. For Markdown files, look for lines starting with '#' (up to max_level).
For reStructuredText files, look for a line immediately followed by an underline made of punctuation.
""" """
headings = [] headings = []
ext = os.path.splitext(filepath)[1].lower() ext = os.path.splitext(filepath)[1].lower()
@ -26,13 +28,11 @@ def extract_headings_from_file(filepath, max_level=MAX_HEADING_LEVEL):
if ext == '.md': if ext == '.md':
in_code_block = False in_code_block = False
for line in f: for line in f:
# Toggle code block state if a line starts with ```
if line.strip().startswith("```"): if line.strip().startswith("```"):
in_code_block = not in_code_block in_code_block = not in_code_block
continue continue
if in_code_block: if in_code_block:
continue continue
# Match Markdown headings: one or more '#' followed by a space and the title.
match = re.match(r'^(#{1,})\s+(.*)$', line) match = re.match(r'^(#{1,})\s+(.*)$', line)
if match: if match:
level = len(match.group(1)) level = len(match.group(1))
@ -43,12 +43,11 @@ def extract_headings_from_file(filepath, max_level=MAX_HEADING_LEVEL):
headings.append({'level': level, 'text': heading_text, 'anchor': anchor}) headings.append({'level': level, 'text': heading_text, 'anchor': anchor})
elif ext == '.rst': elif ext == '.rst':
lines = f.readlines() lines = f.readlines()
# Look for reST headings: a line followed by an underline made of punctuation.
for i in range(len(lines)-1): for i in range(len(lines)-1):
text_line = lines[i].rstrip("\n") text_line = lines[i].rstrip("\n")
underline = lines[i+1].rstrip("\n") underline = lines[i+1].rstrip("\n")
if len(underline) >= 3 and re.fullmatch(r'[-=~\^\+"\'`]+', underline): if len(underline) >= 3 and re.fullmatch(r'[-=~\^\+"\'`]+', underline):
level = 1 # default level; you could adjust based on the punctuation if needed level = 1 # default level; adjust if needed
heading_text = text_line.strip() heading_text = text_line.strip()
anchor = re.sub(r'\s+', '-', heading_text.lower()) anchor = re.sub(r'\s+', '-', heading_text.lower())
anchor = re.sub(r'[^a-z0-9\-]', '', anchor) anchor = re.sub(r'[^a-z0-9\-]', '', anchor)
@ -77,11 +76,50 @@ def group_headings(headings):
def sort_tree(tree): def sort_tree(tree):
""" """
Sorts a list of headings (and their children) first by their 'priority' (if defined, default 1) Sorts a list of headings (and their children) first by their 'priority' (default 1)
and then by the natural sort key of their text. and then by the natural sort key of their text.
""" """
tree.sort(key=lambda x: (x.get('priority', 1), natural_sort_key(x['text']))) tree.sort(key=lambda x: (x.get('priority', 1), natural_sort_key(x['text'])))
def collect_nav_items(dir_path, base_url, current_depth, max_depth):
"""
Recursively collects navigation items from subdirectories.
For each subdirectory, if an 'index.rst' exists (preferred) or a 'readme.md' exists,
the first heading from that file is used as the title.
"""
nav_items = []
# Look for candidate file in this subdirectory (prefer index.rst, then readme.md)
candidate = None
for cand in ['index.rst', 'readme.md']:
candidate_path = os.path.join(dir_path, cand)
if os.path.isfile(candidate_path):
candidate = cand
break
if candidate:
candidate_path = os.path.join(dir_path, candidate)
headings = extract_headings_from_file(candidate_path)
if headings:
title = headings[0]['text']
else:
title = os.path.splitext(candidate)[0].capitalize()
# Build link relative to base_url
link = os.path.join(base_url, os.path.splitext(candidate)[0])
nav_items.append({
'level': 1,
'text': title,
'link': link,
'anchor': '',
'priority': 0
})
# Recurse into subdirectories if within max_depth
if current_depth < max_depth:
for item in os.listdir(dir_path):
full_path = os.path.join(dir_path, item)
if os.path.isdir(full_path):
sub_base_url = os.path.join(base_url, item)
nav_items.extend(collect_nav_items(full_path, sub_base_url, current_depth + 1, max_depth))
return nav_items
def add_local_md_headings(app, pagename, templatename, context, doctree): def add_local_md_headings(app, pagename, templatename, context, doctree):
srcdir = app.srcdir srcdir = app.srcdir
directory = os.path.dirname(pagename) directory = os.path.dirname(pagename)
@ -91,20 +129,28 @@ def add_local_md_headings(app, pagename, templatename, context, doctree):
context['local_md_headings'] = [] context['local_md_headings'] = []
return return
# List all files in the directory. max_nav_depth = getattr(app.config, 'local_nav_max_depth', DEFAULT_MAX_NAV_DEPTH)
# Collect navigation items from subdirectories only
nav_items = []
for item in os.listdir(abs_dir):
full_path = os.path.join(abs_dir, item)
if os.path.isdir(full_path):
nav_items.extend(collect_nav_items(full_path, os.path.join(directory, item), current_depth=1, max_depth=max_nav_depth))
# Process files in the current directory.
files = os.listdir(abs_dir) files = os.listdir(abs_dir)
files_lower = [f.lower() for f in files] files_lower = [f.lower() for f in files]
# If both index.rst and README.md exist, filter out README.md (case-insensitive) # If both index.rst and readme.md exist in the current directory, keep only index.rst.
if "index.rst" in files_lower: if "index.rst" in files_lower:
files = [f for f in files if f.lower() != "readme.md"] files = [f for f in files if f.lower() != "readme.md"]
local_md_headings = [] local_md_headings = []
for file in files: for file in files:
if file.endswith('.md') or file.endswith('.rst'): if file.endswith('.md') or file.endswith('.rst'):
filepath = os.path.join(abs_dir, file) filepath = os.path.join(abs_dir, file)
headings = extract_headings_from_file(filepath) headings = extract_headings_from_file(filepath)
# Determine file priority: index and readme get priority 0; others 1.
basename, _ = os.path.splitext(file) basename, _ = os.path.splitext(file)
# Set priority: index/readme files get priority 0.
if basename.lower() in ['index', 'readme']: if basename.lower() in ['index', 'readme']:
priority = 0 priority = 0
else: else:
@ -118,10 +164,14 @@ def add_local_md_headings(app, pagename, templatename, context, doctree):
'anchor': heading['anchor'], 'anchor': heading['anchor'],
'priority': priority 'priority': priority
}) })
tree = group_headings(local_md_headings) # Combine current directory items with subdirectory nav items.
# If an index or readme from the current directory exists, it will be included only once.
all_items = local_md_headings + nav_items
tree = group_headings(all_items)
sort_tree(tree) sort_tree(tree)
context['local_md_headings'] = tree context['local_md_headings'] = tree
def setup(app): def setup(app):
app.add_config_value('local_nav_max_depth', DEFAULT_MAX_NAV_DEPTH, 'env')
app.connect('html-page-context', add_local_md_headings) app.connect('html-page-context', add_local_md_headings)
return {'version': '0.1', 'parallel_read_safe': True} return {'version': '0.1', 'parallel_read_safe': True}