#!/usr/bin/env python
"""Main generator class for Introligo documentation generation.
Copyright (c) 2025 WT Tech Jakub Brzezowski
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
from jinja2 import Environment, Template
from .errors import IntroligoError
from .godoc_extractor import GoDocExtractor
from .javadoc_extractor import JavaDocExtractor
from .markdown_converter import (
convert_markdown_to_rst,
)
from .page_node import PageNode
from .rustdoc_extractor import RustDocExtractor
from .utils import count_display_width
from .yaml_loader import IncludeLoader
# Support both direct execution and package import
try:
from introligo.hub import DocumentationHub
except ModuleNotFoundError:
from .hub import DocumentationHub
logger = logging.getLogger(__name__)
[docs]
class IntroligoGenerator:
"""Main generator class for processing YAML and creating RST files."""
[docs]
def __init__(
self,
config_file: Path,
output_dir: Path,
template_file: Optional[Path] = None,
dry_run: bool = False,
strict: bool = False,
):
"""Initialize the Introligo generator.
Args:
config_file: Path to the YAML configuration file.
output_dir: Output directory for generated documentation.
template_file: Optional custom Jinja2 template file.
dry_run: If True, only show what would be generated.
strict: If True, fail on any generation error.
"""
self.config_file = config_file
self.output_dir = output_dir
self.generated_dir = output_dir / "generated"
self.template_file = template_file
self.dry_run = dry_run
self.strict = strict
self.config: Dict[str, Any] = {}
self.page_tree: List[PageNode] = []
self.doxygen_config: Dict[str, str] = {}
self.sphinx_config: Dict[str, Any] = {}
self.palette_data: Dict[str, Any] = {}
self.hub: Optional[DocumentationHub] = None
[docs]
def load_config(self) -> None:
"""Load configuration with support for !include directives.
Raises:
IntroligoError: If the config file is not found, has invalid YAML,
or doesn't contain required 'modules' dictionary.
"""
if not self.config_file.exists():
raise IntroligoError(f"Configuration file not found: {self.config_file}")
logger.info(f"đ Loading configuration from {self.config_file}")
try:
with open(self.config_file, encoding="utf-8") as f:
self.config = yaml.load(f, Loader=IncludeLoader)
except yaml.YAMLError as e:
raise IntroligoError(f"Invalid YAML in {self.config_file}: {e}") from e
if not isinstance(self.config, dict):
raise IntroligoError("Configuration must be a YAML dictionary")
modules = self.config.get("modules")
if not isinstance(modules, dict):
# Show deprecation warning if using old structure without hub/discovery
if not self.config.get("hub") and not self.config.get("discovery"):
raise IntroligoError("Configuration must contain a 'modules' dictionary")
else:
# Hub mode - modules will be generated
logger.info("Hub mode detected - modules will be auto-generated")
self.config["modules"] = {}
modules = {}
# Load Doxygen configuration if present
if "doxygen" in self.config:
self.doxygen_config = self.config["doxygen"]
project_name = self.doxygen_config.get("project_name", "default")
logger.info(f"â
Loaded Doxygen configuration: {project_name}")
# Initialize documentation hub if configured
if self.config.get("hub") or self.config.get("discovery"):
self.hub = DocumentationHub(self.config_file, self.config)
logger.info("đ Documentation Hub initialized")
# Discover documentation if enabled
if self.hub.is_enabled():
discovered = self.hub.discover_documentation()
if discovered:
# Generate hub modules and merge with existing modules
hub_modules = self.hub.generate_hub_modules()
self.config["modules"].update(hub_modules)
modules = self.config["modules"]
logger.info(f"â
Hub added {len(hub_modules)} auto-discovered modules")
logger.info(f"â
Loaded configuration with {len(modules)} module(s)")
[docs]
def build_page_tree(self) -> None:
"""Build hierarchical page tree from loaded configuration.
Creates PageNode objects and establishes parent-child relationships
based on the 'parent' field in each page's configuration.
"""
modules = self.config["modules"]
node_map: Dict[str, PageNode] = {}
seen_slugs = set()
for page_id, page_config in modules.items():
if not isinstance(page_config, dict):
logger.warning(f"Skipping invalid page config: {page_id}")
continue
node = PageNode(page_id, page_config)
if node.slug in seen_slugs:
logger.warning(f"Duplicate slug detected: {node.slug} (page {page_id})")
seen_slugs.add(node.slug)
node_map[page_id] = node
root_nodes = []
for node in node_map.values():
parent_id = node.config.get("parent")
if parent_id and parent_id in node_map:
parent_node = node_map[parent_id]
node.parent = parent_node
parent_node.children.append(node)
else:
root_nodes.append(node)
self.page_tree = root_nodes
logger.info(f"Built page tree with {len(root_nodes)} root pages")
[docs]
def get_default_template(self) -> str:
"""Get the default Jinja2 template for RST generation.
Returns:
Default template string with support for rich content sections.
"""
# Load template from package resources
template_path = Path(__file__).parent / "templates" / "default.jinja2"
if template_path.exists():
return template_path.read_text(encoding="utf-8")
# Fallback to inline template if file not found
return """..
This file is AUTO-GENERATED by Introligo.
DO NOT EDIT manually - changes will be overwritten.
To modify this documentation, edit the source YAML configuration
and regenerate using: python -m introligo <config.yaml> -o <output_dir>
{{ title }}
{{ '=' * (title|display_width) }}
{% if description -%}
{{ description }}
{%- else -%}
Documentation for {{ module if module else title }}.
{%- endif %}
{% if overview %}
Overview
--------
{{ overview }}
{% endif %}
{% if features %}
Features
--------
{% for feature in features %}
* {{ feature }}
{% endfor %}
{% endif %}
{% if installation %}
Installation
------------
{% if installation is mapping %}
{% if installation.title %}
{{ installation.title }}
{{ '~' * (installation.title|display_width) }}
{% endif %}
{% if installation.steps %}
{% for step_info in installation.steps %}
**{{ step_info.step }}**
{% if step_info.description %}
{{ step_info.description }}
{% endif %}
{% if step_info.code %}
.. code-block:: bash
{{ step_info.code|indent(4, true) }}
{% endif %}
{% endfor %}
{% endif %}
{% else %}
{{ installation }}
{% endif %}
{% endif %}
{% if requirements %}
Requirements
------------
{% if requirements is string %}
{{ requirements }}
{% elif requirements is iterable %}
{% for req in requirements %}
* {{ req }}
{% endfor %}
{% endif %}
{% endif %}
{% if usage_examples %}
Usage Examples
--------------
{% for example in usage_examples %}
{{ example.title }}
{{ '~' * (example.title|display_width) }}
{% if example.description -%}
{{ example.description }}
{% endif -%}
.. code-block:: {{ example.language or 'python' }}
{{ example.code|indent(4, true) }}
{% endfor %}
{% endif %}
{% if configuration %}
Configuration
-------------
{{ configuration }}
{% endif %}
{% if api_reference %}
API Reference
-------------
{{ api_reference }}
{% endif %}
{% if children %}
Subpages
--------
.. toctree::
:maxdepth: 2
:titlesonly:
{% for child in children %}
{{ child.relative_path }}
{% endfor %}
{% endif %}
{% set has_dox = (
doxygen_file or doxygen_files or doxygen_class or
doxygen_function or doxygen_namespace
) -%}
{% set has_godoc = (
godoc_package or godoc_packages or godoc_function or godoc_type
) -%}
{% set has_javadoc = (
java_package or java_packages or java_source_files or javadoc_path
) -%}
{% set has_rustdoc = (
rustdoc_crate or rustdoc_path
) -%}
{% if module or has_dox or has_godoc or has_javadoc or has_rustdoc -%}
{% if not api_reference %}
API Documentation
-----------------
{% endif %}
{% if language == 'python' or not language %}
.. automodule:: {{ module }}
:members:
:undoc-members:
:show-inheritance:
:private-members:
:special-members: __init__
{% elif language == 'c' or language == 'cpp' %}
{% if doxygen_files %}
{% for file in doxygen_files %}
.. doxygenfile:: {{ file }}
:project: {{ global_doxygen_project }}
{% endfor %}
{% elif doxygen_file %}
.. doxygenfile:: {{ doxygen_file }}
:project: {{ global_doxygen_project }}
{% elif doxygen_class %}
.. doxygenclass:: {{ doxygen_class }}
:project: {{ global_doxygen_project }}
:members:
:undoc-members:
{% elif doxygen_function %}
.. doxygenfunction:: {{ doxygen_function }}
:project: {{ global_doxygen_project }}
{% elif doxygen_namespace %}
.. doxygennamespace:: {{ doxygen_namespace }}
:project: {{ global_doxygen_project }}
:members:
:undoc-members:
{% elif module %}
.. doxygenfile:: {{ module }}
:project: {{ global_doxygen_project }}
{% endif %}
{% elif language == 'go' %}
{% if godoc_extracted_content %}
{{ godoc_extracted_content }}
{% else %}
.. note::
Go documentation extraction was not available. The package may not be installed
or Go may not be available on the build system.
{% if godoc_package %}
**Package:** ``{{ godoc_package }}``
View full documentation at: https://pkg.go.dev/{{ godoc_package }}
{% elif godoc_packages %}
**Packages:**
{% for package in godoc_packages %}
* ``{{ package }}`` - https://pkg.go.dev/{{ package }}
{% endfor %}
{% endif %}
{% endif %}
{% elif language == 'java' %}
{% if javadoc_extracted_content %}
{{ javadoc_extracted_content }}
{% else %}
.. note::
Java documentation extraction was not available. This can happen if:
- The source files are not accessible
- Java/javadoc is not installed on the system
{% if java_package %}
**Package:** ``{{ java_package }}``
To generate documentation:
.. code-block:: bash
javadoc -d docs {{ java_package }}
{% elif java_packages %}
**Packages:**
{% for package in java_packages %}
* ``{{ package }}``
{% endfor %}
To generate documentation:
.. code-block:: bash
javadoc -d docs {{ java_packages|join(' ') }}
{% elif java_source_files %}
**Source Files:**
{% for file in java_source_files %}
* ``{{ file }}``
{% endfor %}
To generate documentation:
.. code-block:: bash
javadoc -d docs {{ java_source_files|join(' ') }}
{% endif %}
{% endif %}
{% elif language == 'rust' %}
{% if rustdoc_extracted_content %}
{{ rustdoc_extracted_content }}
{% else %}
.. note::
Rust documentation extraction was not available. This can happen if:
- Cargo is not installed on the system
- The crate path is not accessible
- The crate has build errors
{% if rustdoc_crate %}
**Crate:** ``{{ rustdoc_crate }}``
To generate documentation:
.. code-block:: bash
cargo doc --open
Or view at: https://docs.rs/{{ rustdoc_crate }}
{% elif rustdoc_path %}
**Crate Path:** ``{{ rustdoc_path }}``
To generate documentation:
.. code-block:: bash
cd {{ rustdoc_path }} && cargo doc --open
{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% if notes %}
Notes
-----
{{ notes }}
{% endif %}
{% if see_also %}
See Also
--------
{% for item in see_also %}
* {{ item }}
{% endfor %}
{% endif %}
{% if references %}
References
----------
{% for ref in references %}
* {{ ref }}
{% endfor %}
{% endif %}
{% if changelog %}
Changelog
---------
{{ changelog }}
{% endif %}
{% if examples_dir %}
Additional Examples
-------------------
Examples can be found in the ``{{ examples_dir }}`` directory.
{% endif %}
{% if workflow %}
Workflow
--------
{% if workflow is mapping %}
{% if workflow.title %}
{{ workflow.title }}
{{ '~' * (workflow.title|display_width) }}
{% endif %}
{% if workflow.description %}
{{ workflow.description }}
{% endif %}
{% if workflow.steps %}
{% for step in workflow.steps %}
{{ loop.index }}. {{ step }}
{% endfor %}
{% endif %}
{% else %}
{{ workflow }}
{% endif %}
{% endif %}
{% if how_it_works %}
How It Works
------------
{% if how_it_works is mapping %}
{% if how_it_works.title %}
{{ how_it_works.title }}
{{ '~' * (how_it_works.title|display_width) }}
{% endif %}
{% if how_it_works.description -%}
{{ how_it_works.description }}
{%- endif %}
{% else %}
{{ how_it_works }}
{% endif %}
{% endif %}
{% if limitations %}
Limitations
-----------
{% for limitation in limitations %}
* {{ limitation }}
{% endfor %}
{% endif %}
{% if troubleshooting %}
Troubleshooting
---------------
{% for item in troubleshooting %}
{% if item is mapping %}
**{{ item.issue }}**
{{ item.solution }}
{% else %}
* {{ item }}
{% endif %}
{% endfor %}
{% endif %}
{% if best_practices %}
Best Practices
--------------
{% for practice in best_practices %}
* {{ practice }}
{% endfor %}
{% endif %}
{% if python_api %}
Python API
----------
{% for example in python_api %}
{{ example.title }}
{{ '~' * (example.title|display_width) }}
{% if example.description %}
{{ example.description }}
{% endif %}
.. code-block:: {{ example.language or 'python' }}
{{ example.code|indent(4, true) }}
{% endfor %}
{% endif %}
{% if examples %}
Examples
--------
{% for example in examples %}
{{ example.title }}
{{ '~' * (example.title|display_width) }}
{% if example.description %}
{{ example.description }}
{% endif %}
{% if example.code %}
.. code-block:: {{ example.language or 'bash' }}
{{ example.code|indent(4, true) }}
{% endif %}
{% endfor %}
{% endif %}
{% if related_tools %}
Related Tools
-------------
{% for tool in related_tools %}
{% if tool is mapping %}
* **{{ tool.name }}**: {{ tool.description }}{% if tool.url %}
{{ tool.url }}{% endif %}
{% else %}
* {{ tool }}
{% endif %}
{% endfor %}
{% endif %}
{% if custom_sections %}
{% for section in custom_sections %}
{{ section.title }}
{{ '-' * (section.title|display_width) }}
{{ section.content }}
{% endfor %}
{% endif %}
{% if rst_includes %}
{% for rst_content in rst_includes %}
{{ rst_content }}
{% endfor %}
{% endif %}
{% if markdown_includes %}
{% for markdown_content in markdown_includes %}
{{ markdown_content }}
{% endfor %}
{% endif %}
{% if latex_includes %}
{% for latex_content in latex_includes %}
{{ latex_content }}
{% endfor %}
{% endif %}
{% if file_includes %}
{% for file_content in file_includes %}
{{ file_content }}
{% endfor %}
{% endif %}
"""
[docs]
def load_template(self) -> Template:
"""Load template and add custom filters.
Returns:
Configured Jinja2 Template object with custom filters.
"""
if self.template_file and self.template_file.exists():
template_content = self.template_file.read_text(encoding="utf-8")
logger.info(f"Using custom template: {self.template_file}")
else:
template_content = self.get_default_template()
logger.info("Using enhanced default template")
# Create environment with custom filter
env = Environment()
env.filters["display_width"] = count_display_width
return env.from_string(template_content)
[docs]
def process_usage_examples(self, examples: Any) -> List[Dict[str, Any]]:
"""Process usage examples to ensure they're in the right format.
Args:
examples: Usage examples in various formats (dict, list, or string).
Returns:
List of normalized example dictionaries with title, description,
language, and code fields.
"""
if not examples:
return []
processed = []
if isinstance(examples, list):
for example in examples:
if isinstance(example, dict):
processed.append(
{
"title": example.get("title", "Example"),
"description": example.get("description", ""),
"language": example.get("language", "python"),
"code": example.get("code", ""),
}
)
elif isinstance(example, str):
# Simple string example
processed.append(
{
"title": "Example",
"description": "",
"language": "python",
"code": example,
}
)
elif isinstance(examples, dict):
# Single example as dict
processed.append(
{
"title": examples.get("title", "Example"),
"description": examples.get("description", ""),
"language": examples.get("language", "python"),
"code": examples.get("code", ""),
}
)
elif isinstance(examples, str):
# Single example as string
processed.append(
{"title": "Example", "description": "", "language": "python", "code": examples}
)
return processed
[docs]
def include_markdown_file(self, markdown_path: str) -> str:
"""Include a markdown file and convert it to reStructuredText-compatible format.
Args:
markdown_path: Path to the markdown file (relative to config file).
Returns:
The content of the markdown file converted to RST-compatible format.
Raises:
IntroligoError: If the markdown file cannot be read.
"""
# Resolve path relative to the config file's directory
md_path_obj = Path(markdown_path)
if not md_path_obj.is_absolute():
md_path_obj = self.config_file.parent / markdown_path
if not md_path_obj.exists():
raise IntroligoError(f"Markdown file not found: {md_path_obj}")
try:
content = md_path_obj.read_text(encoding="utf-8")
# Convert basic markdown to RST
content = convert_markdown_to_rst(content)
logger.info(f" Included markdown: {md_path_obj}")
return content
except Exception as e:
raise IntroligoError(f"Error reading markdown file {md_path_obj}: {e}") from e
[docs]
def include_latex_file(self, latex_path: str) -> str:
"""Include a LaTeX file as a math directive in reStructuredText.
Args:
latex_path: Path to the LaTeX file (relative to config file).
Returns:
The content of the LaTeX file wrapped in RST math directive.
Raises:
IntroligoError: If the LaTeX file cannot be read.
"""
# Resolve path relative to the config file's directory
latex_path_obj = Path(latex_path)
if not latex_path_obj.is_absolute():
latex_path_obj = self.config_file.parent / latex_path
if not latex_path_obj.exists():
raise IntroligoError(f"LaTeX file not found: {latex_path_obj}")
try:
content = latex_path_obj.read_text(encoding="utf-8")
# Wrap LaTeX content in RST math directive
rst_content = self._convert_latex_to_rst(content)
logger.info(f" đ Included LaTeX: {latex_path_obj}")
return rst_content
except Exception as e:
raise IntroligoError(f"Error reading LaTeX file {latex_path_obj}: {e}") from e
[docs]
def include_rst_file(self, rst_path: str) -> str:
"""Include a reStructuredText file as-is.
Args:
rst_path: Path to the RST file (relative to config file).
Returns:
The content of the RST file.
Raises:
IntroligoError: If the RST file cannot be read.
"""
# Resolve path relative to the config file's directory
rst_path_obj = Path(rst_path)
if not rst_path_obj.is_absolute():
rst_path_obj = self.config_file.parent / rst_path
if not rst_path_obj.exists():
raise IntroligoError(f"RST file not found: {rst_path_obj}")
try:
content = rst_path_obj.read_text(encoding="utf-8")
logger.info(f" Included RST: {rst_path_obj}")
return content
except Exception as e:
raise IntroligoError(f"Error reading RST file {rst_path_obj}: {e}") from e
[docs]
def include_txt_file(self, txt_path: str) -> str:
"""Include a text file as a literal block in reStructuredText.
Args:
txt_path: Path to the text file (relative to config file).
Returns:
The content of the text file wrapped in literal block.
Raises:
IntroligoError: If the text file cannot be read.
"""
# Resolve path relative to the config file's directory
txt_path_obj = Path(txt_path)
if not txt_path_obj.is_absolute():
txt_path_obj = self.config_file.parent / txt_path
if not txt_path_obj.exists():
raise IntroligoError(f"Text file not found: {txt_path_obj}")
try:
content = txt_path_obj.read_text(encoding="utf-8")
# Wrap in literal block
rst_content = "::\n\n"
for line in content.split("\n"):
rst_content += " " + line + "\n"
logger.info(f" Included text: {txt_path_obj}")
return rst_content
except Exception as e:
raise IntroligoError(f"Error reading text file {txt_path_obj}: {e}") from e
[docs]
def include_file(self, file_path: str) -> str:
"""Include a file with auto-detection based on file extension.
Supports:
- .rst: included as-is
- .md: converted to RST
- .tex: wrapped in math directive
- .txt: wrapped in literal block
Args:
file_path: Path to the file (relative to config file).
Returns:
The processed content of the file.
Raises:
IntroligoError: If the file cannot be read or type is unsupported.
"""
path_obj = Path(file_path)
if not path_obj.is_absolute():
path_obj = self.config_file.parent / file_path
if not path_obj.exists():
raise IntroligoError(f"Include file not found: {path_obj}")
suffix = path_obj.suffix.lower()
filename = path_obj.name.upper()
# Check for common text files without extensions (LICENSE, README, etc.)
text_file_names = ["LICENSE", "COPYING", "AUTHORS", "CONTRIBUTORS", "NOTICE"]
is_text_file = filename in text_file_names or filename.startswith("LICENSE")
if suffix == ".rst":
return self.include_rst_file(file_path)
elif suffix == ".md":
return self.include_markdown_file(file_path)
elif suffix == ".tex":
return self.include_latex_file(file_path)
elif suffix == ".txt" or is_text_file:
return self.include_txt_file(file_path)
else:
raise IntroligoError(
f"Unsupported file type '{suffix}' for file: {path_obj}. "
f"Supported types: .rst, .md, .tex, .txt"
)
[docs]
def _convert_latex_to_rst(self, latex: str) -> str:
"""Convert LaTeX content to reStructuredText math directive.
Args:
latex: LaTeX content to convert.
Returns:
RST-formatted math content.
"""
# Strip common LaTeX document wrappers if present
content = latex.strip()
# Remove document class and begin/end document if present
lines = content.split("\n")
filtered_lines = []
skip_preamble = False
for line in lines:
stripped = line.strip()
# Skip common LaTeX document commands
if stripped.startswith("\\documentclass") or stripped.startswith("\\usepackage"):
skip_preamble = True
continue
if stripped == "\\begin{document}":
skip_preamble = False
continue
if stripped == "\\end{document}":
break
if not skip_preamble:
filtered_lines.append(line)
clean_content = "\n".join(filtered_lines).strip()
# Wrap in RST math directive
result = [".. math::", ""]
for line in clean_content.split("\n"):
result.append(" " + line)
result.append("")
return "\n".join(result)
[docs]
def generate_rst_content(self, node: PageNode, template: Template) -> str:
"""Generate RST content with enhanced features support.
Args:
node: PageNode containing page configuration.
template: Jinja2 template for rendering.
Returns:
Generated RST content as a string.
"""
config = node.config
# Process children for toctree
children_info = []
if node.children:
for child in node.children:
current_output_dir = node.get_output_dir(self.generated_dir)
relative_path = child.get_relative_path_from(current_output_dir, self.generated_dir)
children_info.append({"title": child.title, "relative_path": relative_path})
# Build context with all possible fields
# Handle doxygen_files (list) or doxygen_file (single string)
doxygen_file = config.get("doxygen_file", "")
doxygen_files = config.get("doxygen_files", [])
# If doxygen_file is set but doxygen_files is not, convert to list
if doxygen_file and not doxygen_files:
doxygen_files = [doxygen_file]
# Process markdown includes
markdown_includes = config.get("markdown_includes", [])
if isinstance(markdown_includes, str):
markdown_includes = [markdown_includes]
markdown_content = []
for md_path in markdown_includes:
try:
content = self.include_markdown_file(md_path)
markdown_content.append(content)
except IntroligoError as e:
logger.warning(f"{e}")
# Process LaTeX includes
latex_includes = config.get("latex_includes", [])
if isinstance(latex_includes, str):
latex_includes = [latex_includes]
latex_content = []
for latex_path in latex_includes:
try:
content = self.include_latex_file(latex_path)
latex_content.append(content)
except IntroligoError as e:
logger.warning(f"{e}")
# Process RST includes
rst_includes = config.get("rst_includes", [])
if isinstance(rst_includes, str):
rst_includes = [rst_includes]
rst_content = []
for rst_path in rst_includes:
try:
content = self.include_rst_file(rst_path)
rst_content.append(content)
except IntroligoError as e:
logger.warning(f"{e}")
# Process generic file includes (auto-detection)
file_includes = config.get("file_includes", [])
if isinstance(file_includes, str):
file_includes = [file_includes]
file_content = []
for file_path in file_includes:
try:
content = self.include_file(file_path)
file_content.append(content)
except IntroligoError as e:
logger.warning(f"{e}")
# Handle godoc_packages (list) or godoc_package (single string)
godoc_package = config.get("godoc_package", "")
godoc_packages = config.get("godoc_packages", [])
# If godoc_package is set but godoc_packages is not, convert to list
if godoc_package and not godoc_packages:
godoc_packages = [godoc_package]
# Extract Go documentation if Go language is specified
godoc_extracted_content = ""
# Check if manual documentation is provided
manual_godoc = config.get("godoc_manual_content")
if config.get("language") == "go" and (godoc_package or godoc_packages):
if manual_godoc:
# Use manually provided documentation
godoc_extracted_content = manual_godoc
logger.info("Using manually provided Go documentation")
else:
# Try automatic extraction
# Determine package path (relative to config file)
godoc_path = config.get("godoc_path")
if godoc_path:
godoc_path_obj = Path(godoc_path)
if not godoc_path_obj.is_absolute():
godoc_path_obj = self.config_file.parent / godoc_path
else:
# Try to use config file's parent directory
godoc_path_obj = self.config_file.parent
extractor = GoDocExtractor(package_path=godoc_path_obj)
if godoc_packages:
# Extract for multiple packages
results = extractor.extract_multiple_packages(godoc_packages)
content_parts = []
for pkg_name, success, content in results:
if success:
content_parts.append(f"Package: ``{pkg_name}``\n")
content_parts.append("~" * (len(pkg_name) + 11) + "\n\n")
content_parts.append(content)
content_parts.append("\n\n")
godoc_extracted_content = "".join(content_parts)
elif godoc_package:
# Extract for single package
success, content = extractor.extract_and_convert(godoc_package)
godoc_extracted_content = content
# Handle Java documentation extraction
java_source_files = config.get("java_source_files", [])
java_package = config.get("java_package", "")
java_packages = config.get("java_packages", [])
javadoc_path = config.get("javadoc_path", "")
# Extract Java documentation if Java language is specified
javadoc_extracted_content = ""
# Check if manual documentation is provided
manual_javadoc = config.get("java_manual_content")
if config.get("language") == "java":
if manual_javadoc:
# Use manually provided documentation
javadoc_extracted_content = manual_javadoc
logger.info("Using manually provided Java documentation")
else:
# Try automatic extraction
# Determine source path (relative to config file)
java_source_path = config.get("java_source_path")
if java_source_path:
java_source_path_obj = Path(java_source_path)
if not java_source_path_obj.is_absolute():
java_source_path_obj = self.config_file.parent / java_source_path
else:
# Try to use config file's parent directory
java_source_path_obj = self.config_file.parent
java_extractor = JavaDocExtractor(source_path=java_source_path_obj)
if java_packages:
# Extract for multiple packages
content_parts = []
for pkg_name in java_packages:
# Convert package name to directory path (com.example -> com/example)
pkg_path = java_source_path_obj / pkg_name.replace(".", "/")
success, content = java_extractor.extract_package(pkg_path, pkg_name)
if success:
content_parts.append(content)
content_parts.append("\n\n")
javadoc_extracted_content = "".join(content_parts)
elif java_package:
# Extract for single package
pkg_path = java_source_path_obj / java_package.replace(".", "/")
success, content = java_extractor.extract_package(pkg_path, java_package)
javadoc_extracted_content = content
elif java_source_files:
# Extract from specific source files
java_file_paths = []
for file_path in java_source_files:
file_path_obj = Path(file_path)
if not file_path_obj.is_absolute():
file_path_obj = self.config_file.parent / file_path
java_file_paths.append(file_path_obj)
results = java_extractor.extract_multiple_files(java_file_paths)
content_parts = []
for filename, success, content in results:
if success:
content_parts.append(f"{filename}\n")
content_parts.append("~" * len(filename) + "\n\n")
content_parts.append(content)
content_parts.append("\n\n")
javadoc_extracted_content = "".join(content_parts)
# Handle Rust documentation extraction
rustdoc_crate = config.get("rustdoc_crate", "")
rustdoc_path = config.get("rustdoc_path", "")
# Extract Rust documentation if Rust language is specified
rustdoc_extracted_content = ""
# Check if manual documentation is provided
manual_rustdoc = config.get("rustdoc_manual_content")
if config.get("language") == "rust":
if manual_rustdoc:
# Use manually provided documentation
rustdoc_extracted_content = manual_rustdoc
logger.info("Using manually provided Rust documentation")
else:
# Try automatic extraction
# Determine crate path (relative to config file)
if rustdoc_path:
rustdoc_path_obj = Path(rustdoc_path)
if not rustdoc_path_obj.is_absolute():
rustdoc_path_obj = self.config_file.parent / rustdoc_path
else:
# Try to use config file's parent directory
rustdoc_path_obj = self.config_file.parent
rust_extractor = RustDocExtractor(crate_path=rustdoc_path_obj)
success, content = rust_extractor.extract_and_convert(rustdoc_crate)
rustdoc_extracted_content = content
context = {
"title": node.title,
"module": config.get("module", ""),
"language": config.get("language", "python"),
"doxygen_file": doxygen_file,
"doxygen_files": doxygen_files,
"doxygen_class": config.get("doxygen_class", ""),
"doxygen_function": config.get("doxygen_function", ""),
"doxygen_namespace": config.get("doxygen_namespace", ""),
"global_doxygen_project": self.doxygen_config.get("project_name", "default"),
"godoc_package": godoc_package,
"godoc_packages": godoc_packages,
"godoc_function": config.get("godoc_function", ""),
"godoc_type": config.get("godoc_type", ""),
"godoc_extracted_content": godoc_extracted_content,
"java_source_files": java_source_files,
"java_package": java_package,
"java_packages": java_packages,
"javadoc_path": javadoc_path,
"javadoc_extracted_content": javadoc_extracted_content,
"rustdoc_crate": rustdoc_crate,
"rustdoc_path": rustdoc_path,
"rustdoc_extracted_content": rustdoc_extracted_content,
"description": config.get("description", ""),
"overview": config.get("overview", ""),
"features": config.get("features", []),
"installation": config.get("installation", ""),
"requirements": config.get("requirements", ""),
"usage_examples": self.process_usage_examples(config.get("usage_examples")),
"configuration": config.get("configuration", ""),
"api_reference": config.get("api_reference", ""),
"children": children_info,
"notes": config.get("notes", ""),
"see_also": config.get("see_also", []),
"references": config.get("references", []),
"changelog": config.get("changelog", ""),
"examples_dir": config.get("examples_dir", ""),
"workflow": config.get("workflow", ""),
"how_it_works": config.get("how_it_works", ""),
"limitations": config.get("limitations", []),
"troubleshooting": config.get("troubleshooting", []),
"best_practices": config.get("best_practices", []),
"python_api": self.process_usage_examples(config.get("python_api")),
"examples": self.process_usage_examples(config.get("examples")),
"related_tools": config.get("related_tools", []),
"custom_sections": config.get("custom_sections", []),
"markdown_includes": markdown_content,
"latex_includes": latex_content,
"rst_includes": rst_content,
"file_includes": file_content,
}
# Clean up empty values, but keep language field
context = {k: v for k, v in context.items() if v or k == "language"}
return template.render(context)
[docs]
def generate_index(self, root_nodes: List[PageNode]) -> str:
"""Generate the main index.rst file.
Args:
root_nodes: List of root PageNode objects to include in index.
Returns:
Generated index.rst content as a string.
"""
index_config = self.config.get("index", {})
title = index_config.get("title", "API Documentation")
description = index_config.get("description", "Generated API documentation.")
overview = index_config.get("overview", "")
content = f"""..
This file is AUTO-GENERATED by Introligo.
DO NOT EDIT manually - changes will be overwritten.
To modify this documentation, edit the source YAML configuration
and regenerate using: python -m introligo <config.yaml> -o <output_dir>
{title}
{"=" * count_display_width(title)}
{description}
"""
if overview:
content += f"""{overview}
"""
content += """.. toctree::
:maxdepth: 2
:caption: Documentation:
"""
for node in sorted(root_nodes, key=lambda n: n.title):
relative_path = node.get_relative_path_from(self.generated_dir, self.generated_dir)
content += f" generated/{relative_path}\n"
# Add footer note
content += """
.. note::
This documentation was generated from yaml composition using the
**Introligo** tool created by **WT Tech Jakub Brzezowski**.
"""
# Add any additional index sections
if "custom_sections" in index_config:
for section in index_config["custom_sections"]:
section_title = section.get("title", "Section")
content += f"""
{section_title}
{"-" * count_display_width(section_title)}
{section.get("content", "")}
"""
return content
[docs]
def generate_all_nodes(
self,
nodes: List[PageNode],
template: Template,
strict: bool = False,
) -> Dict[str, Tuple[str, Path]]:
"""Generate all RST files for the page tree.
Args:
nodes: List of PageNode objects to generate.
template: Jinja2 template for rendering.
strict: If True, raise exception on generation errors.
Returns:
Dictionary mapping file paths to tuples of (content, Path).
Raises:
IntroligoError: If strict mode is enabled and generation fails.
"""
generated_files = {}
for node in nodes:
try:
content = self.generate_rst_content(node, template)
output_file = node.get_output_file(self.generated_dir)
generated_files[str(output_file)] = (content, output_file)
logger.info(f" Generated: {node.title} -> {output_file}")
if node.children:
child_files = self.generate_all_nodes(node.children, template, strict)
generated_files.update(child_files)
except Exception as e:
if strict:
raise IntroligoError(
f"Strict mode: failed to generate {node.page_id}: {e}"
) from e
logger.error(f"Failed to generate {node.page_id}: {e}")
continue
return generated_files
[docs]
def generate_all(self) -> Dict[str, Tuple[str, Path]]:
"""Main generation method.
Loads configuration, builds page tree, and generates all RST files.
Returns:
Dictionary mapping file paths to tuples of (content, Path).
"""
self.load_config()
self.load_sphinx_config()
self.build_page_tree()
template = self.load_template()
logger.info("Generating RST files for page tree...")
generated_files = self.generate_all_nodes(self.page_tree, template, self.strict)
if self.config.get("generate_index", True):
index_content = self.generate_index(self.page_tree)
index_path = self.output_dir / "index.rst"
generated_files[str(index_path)] = (index_content, index_path)
logger.info(" đ Generated: index.rst")
return generated_files
[docs]
def write_files(self, generated_files: Dict[str, Tuple[str, Path]]) -> None:
"""Write all generated files to disk.
Args:
generated_files: Dictionary mapping file paths to (content, Path) tuples.
"""
if self.dry_run:
logger.info("DRY RUN - Would generate:")
for _, full_path in generated_files.values():
logger.info(f" {full_path}")
return
for content, full_path in generated_files.values():
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content, encoding="utf-8")
logger.info(f"â
Wrote: {full_path}")
[docs]
def generate_breathe_config(self) -> Optional[str]:
"""Generate Breathe configuration snippet for Sphinx conf.py.
Returns:
Breathe configuration snippet as a string, or None if no Doxygen config.
"""
if not self.doxygen_config:
return None
project_name = self.doxygen_config.get("project_name", "default")
xml_path = self.doxygen_config.get("xml_path", "")
if not xml_path:
return None
# Convert to absolute path if relative
# Relative paths are resolved from the config file's directory
xml_path_obj = Path(xml_path)
if not xml_path_obj.is_absolute():
# Resolve relative to config file directory
xml_path_obj = self.config_file.parent / xml_path
xml_path_str = str(xml_path_obj.resolve())
config = f"""# Breathe Configuration for Doxygen Integration
# AUTO-GENERATED by Introligo
#
# WARNING: This file is AUTO-GENERATED and should NOT be edited manually.
# Any manual changes will be OVERWRITTEN when regenerated.
#
# To modify Breathe configuration:
# 1. Edit your introligo YAML configuration file (doxygen section)
# 2. Regenerate using: python -m introligo <config.yaml> -o <output_dir>
from pathlib import Path
breathe_projects = {{
"{project_name}": r"{xml_path_str}"
}}
breathe_default_project = "{project_name}"
"""
return config
[docs]
def load_palette(self, palette_name: str) -> Dict[str, Any]:
"""Load a color palette from the palettes directory.
Args:
palette_name: Name of the palette (without .yaml extension) or path to palette file.
Returns:
Dictionary containing palette data.
Raises:
IntroligoError: If the palette file cannot be loaded.
"""
# Check if it's a full path or just a name
palette_path = Path(palette_name)
if not palette_path.exists():
# Try as a built-in palette
package_palette = Path(__file__).parent / "palettes" / f"{palette_name}.yaml"
if package_palette.exists():
palette_path = package_palette
else:
# Try relative to config file
config_relative = self.config_file.parent / palette_name
if config_relative.exists():
palette_path = config_relative
else:
raise IntroligoError(
f"Palette not found: {palette_name}. "
f"Tried: {palette_path}, {package_palette}, {config_relative}"
)
try:
with open(palette_path, encoding="utf-8") as f:
palette_data: Dict[str, Any] = yaml.load(f, Loader=IncludeLoader)
logger.info(f"â
Loaded palette: {palette_data.get('name', palette_name)}")
return palette_data
except Exception as e:
raise IntroligoError(f"Error loading palette {palette_path}: {e}") from e
[docs]
def resolve_color_references(
self, palette_colors: Dict[str, Any], theme_mapping: Dict[str, str]
) -> Dict[str, str]:
"""Resolve color references in theme mapping.
Color references are in the format {color_group.shade}, e.g., {cosmic_dawn.3}.
Args:
palette_colors: Dictionary of color definitions from palette.
theme_mapping: Dictionary of theme variables with potential color references.
Returns:
Dictionary with all color references resolved to actual hex values.
Note: Variable names starting with '--' will have the prefix removed since
Furo adds it automatically when generating CSS.
"""
resolved = {}
for key, value in theme_mapping.items():
# Strip '--' prefix from variable names since Furo adds it automatically
clean_key = key.lstrip("-") if key.startswith("--") else key
if isinstance(value, str) and value.startswith("{") and value.endswith("}"):
# Extract reference like {cosmic_dawn.3}
ref = value[1:-1] # Remove braces
parts = ref.split(".")
if len(parts) == 2:
color_group, shade = parts
if color_group in palette_colors:
# Try shade as both string and int
color_value = palette_colors[color_group].get(
shade,
palette_colors[color_group].get(
int(shade) if shade.isdigit() else shade
),
)
if color_value:
resolved[clean_key] = color_value
else:
logger.warning(
f"Color reference not found: {ref} "
f"(available shades: {list(palette_colors[color_group].keys())})"
)
resolved[clean_key] = value
else:
logger.warning(
f"Color group not found: {color_group} "
f"(available groups: {list(palette_colors.keys())})"
)
resolved[clean_key] = value
else:
logger.warning(f"Invalid color reference format: {value}")
resolved[clean_key] = value
else:
resolved[clean_key] = value
return resolved
[docs]
def flatten_palette_colors(self, palette_colors: Dict[str, Any]) -> Dict[str, str]:
"""Flatten nested color palette into CSS variable format.
Args:
palette_colors: Nested color definitions from palette.
Returns:
Flattened dictionary with CSS variable names.
"""
flattened = {}
for group_name, shades in palette_colors.items():
if isinstance(shades, dict):
for shade, color in shades.items():
var_name = f"{group_name}-{shade}"
flattened[var_name] = color
return flattened
[docs]
def detect_project_languages(self) -> set:
"""Detect programming languages used in the project.
Returns:
Set of language strings found in module configurations.
"""
languages = set()
modules = self.config.get("modules", {})
def scan_module(module_config):
"""Recursively scan module configuration for language field."""
if isinstance(module_config, dict):
# Check if this module has a language field
if "language" in module_config:
languages.add(module_config["language"])
# Check if this module has Python-specific fields
elif "module" in module_config:
languages.add("python")
# Check if this module has C/C++-specific fields
if (
any(
key in module_config
for key in [
"doxygen_file",
"doxygen_files",
"doxygen_class",
"doxygen_function",
"doxygen_namespace",
]
)
and "language" not in module_config
):
# Default to C if no language specified for Doxygen content
languages.add("c")
# Check if this module has Go-specific fields
if any(
key in module_config
for key in [
"godoc_package",
"godoc_packages",
"godoc_function",
"godoc_type",
]
):
languages.add("go")
# Check if this module has Java-specific fields
if any(
key in module_config
for key in [
"java_source_files",
"java_package",
"java_packages",
"javadoc_path",
]
):
languages.add("java")
# Check if this module has Rust-specific fields
if any(
key in module_config
for key in [
"rustdoc_crate",
"rustdoc_path",
]
):
languages.add("rust")
for module_config in modules.values():
scan_module(module_config)
# If no languages detected, assume Python as default
if not languages:
languages.add("python")
return languages
[docs]
def load_sphinx_config(self) -> None:
"""Load Sphinx configuration from the config file."""
if "sphinx" not in self.config:
logger.info("No 'sphinx' configuration found, skipping conf.py generation")
return
self.sphinx_config = self.config["sphinx"]
# Auto-configure extensions based on project type
self.auto_configure_extensions()
# Load palette if specified
palette_ref = self.sphinx_config.get("palette")
if palette_ref:
if isinstance(palette_ref, str):
# Load palette from file
self.palette_data = self.load_palette(palette_ref)
elif isinstance(palette_ref, dict):
# Inline palette definition
self.palette_data = palette_ref
logger.info("â
Loaded Sphinx configuration")
[docs]
def generate_conf_py(self) -> Optional[str]:
"""Generate conf.py content from Sphinx configuration.
Returns:
Generated conf.py content as a string, or None if no Sphinx config.
"""
if not self.sphinx_config:
return None
# Load conf.py template
template_path = Path(__file__).parent / "templates" / "conf.py.jinja2"
if not template_path.exists():
logger.warning("conf.py template not found")
return None
template_content = template_path.read_text(encoding="utf-8")
env = Environment()
template = env.from_string(template_content)
# Prepare template context
context = {
"config_file_name": self.config_file.name,
"sphinx": self.sphinx_config,
"has_breathe": bool(self.doxygen_config),
}
# Process palette if available
if self.palette_data:
palette_colors = self.palette_data.get("colors", {})
light_mode = self.palette_data.get("light_mode", {})
dark_mode = self.palette_data.get("dark_mode", {})
# Resolve color references
if palette_colors:
context["palette_raw_colors"] = self.flatten_palette_colors(palette_colors)
context["light_palette"] = self.resolve_color_references(palette_colors, light_mode)
context["dark_palette"] = self.resolve_color_references(palette_colors, dark_mode)
else:
context["light_palette"] = light_mode
context["dark_palette"] = dark_mode
context["has_light_palette"] = bool(light_mode)
context["has_dark_palette"] = bool(dark_mode)
context["palette_name"] = self.palette_data.get("name", "")
else:
context["has_light_palette"] = False
context["has_dark_palette"] = False
# Check if we have any theme options
context["has_theme_options"] = bool(
self.sphinx_config.get("html_theme_options")
or context.get("has_light_palette")
or context.get("has_dark_palette")
)
return template.render(context)