From 5879198080235a2604e05f29faacf90ca82e2d0c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:35:47 +0000 Subject: [PATCH 1/9] Add navigation tracking and YAML generation to FernRenderer - Add navigation tracking to FernRenderer with _navigation_items list - Implement _track_navigation_item method to track rendered packages/modules - Add _generate_slug method to create URL-friendly slugs - Add slug to frontmatter in render_item method for predictable navigation - Implement build_navigation_tree method to create hierarchical structure - Add generate_navigation_yaml method to output Fern-compatible YAML - Modify CLI to support --nav-output flag for navigation YAML generation - Create single FernRenderer instance in CLI to track across all items - Add PyYAML dependency to pyproject.toml - Update test regression files with new expected output - Fix all ruff linting issues and mypy type checking errors Co-Authored-By: ryanstep@buildwithfern.com --- pyproject.toml | 3 +- src/autodoc2/cli.py | 29 ++- src/autodoc2/render/fern_.py | 214 +++++++++++++++--- tests/test_render/test_basic_fern_.md | 56 +++-- .../test_render/test_config_options_fern_.md | 8 + 5 files changed, 248 insertions(+), 62 deletions(-) create mode 100644 tests/test_render/test_config_options_fern_.md diff --git a/pyproject.toml b/pyproject.toml index e8ce953..5d63c5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ requires-python = ">=3.8" dependencies = [ "astroid>=2.7,<4", "tomli; python_version<'3.11'", - "typing-extensions" + "typing-extensions", + "PyYAML>=5.1" ] [project.optional-dependencies] diff --git a/src/autodoc2/cli.py b/src/autodoc2/cli.py index ec6d467..dd79e1f 100644 --- a/src/autodoc2/cli.py +++ b/src/autodoc2/cli.py @@ -199,6 +199,11 @@ def write( output: Path = typer.Option("_autodoc", help="Folder to write to"), clean: bool = typer.Option(False, "-c", "--clean", help="Remove old files"), renderer: str = typer.Option("fern", "-r", "--renderer", help="Renderer to use: fern, rst, myst"), + nav_output: t.Optional[Path] = typer.Option( + None, + "--nav-output", + help="Path to write navigation YAML file (Fern renderer only)", + ), ) -> None: """Create sphinx files for a python module or package.""" # gather the module @@ -260,7 +265,7 @@ def _warn(msg: str, type_: WarningSubtypes) -> None: # Set renderer based on CLI option if renderer == "rst": from autodoc2.render.rst_ import RstRenderer - render_class = RstRenderer + render_class: t.Any = RstRenderer elif renderer == "myst": from autodoc2.render.myst_ import MystRenderer render_class = MystRenderer @@ -271,18 +276,30 @@ def _warn(msg: str, type_: WarningSubtypes) -> None: console.print(f"[red]Error[/red] Unknown renderer: {renderer}") raise typer.Exit(1) + fern_renderer: t.Any = None + if renderer == "fern": + fern_renderer = render_class(db, config, warn=_warn) + for mod_name in to_write: progress.update(task, advance=1, description=mod_name) - content = "\n".join( - render_class(db, config, warn=_warn).render_item(mod_name) - ) + if fern_renderer: + content = "\n".join(fern_renderer.render_item(mod_name)) + else: + content = "\n".join( + render_class(db, config, warn=_warn).render_item(mod_name) + ) out_path = output / (mod_name + render_class.EXTENSION) paths.append(out_path) if out_path.exists() and out_path.read_text("utf8") == content: - # Don't write the file if it hasn't changed - # this means that sphinx doesn't mark it for rebuild (mtime based) continue out_path.write_text(content, "utf8") + + if nav_output and fern_renderer: + nav_yaml = fern_renderer.generate_navigation_yaml() + if nav_yaml: + nav_output.parent.mkdir(parents=True, exist_ok=True) + nav_output.write_text(nav_yaml, "utf8") + console.print(f"[green]Navigation YAML written[/green]: {nav_output}") # remove any files that are no longer needed if clean: diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index 37c64a0..6399c6b 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -19,6 +19,11 @@ class FernRenderer(RendererBase): EXTENSION = ".md" + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + """Initialize the renderer with navigation tracking.""" + super().__init__(*args, **kwargs) + self._navigation_items: list[dict[str, t.Any]] = [] + def render_item(self, full_name: str) -> t.Iterable[str]: """Render a single item by dispatching to the appropriate method.""" item = self.get_item(full_name) @@ -27,10 +32,16 @@ def render_item(self, full_name: str) -> t.Iterable[str]: type_ = item["type"] + # Track this item for navigation generation (only packages and modules) + if type_ in ("package", "module"): + self._track_navigation_item(item) + # Add frontmatter for API reference pages (packages and modules) if type_ in ("package", "module"): yield "---" yield "layout: overview" + slug = self._generate_slug(full_name) + yield f"slug: {slug}" yield "---" yield "" @@ -57,7 +68,6 @@ def render_item(self, full_name: str) -> t.Iterable[str]: def render_function(self, item: ItemData) -> t.Iterable[str]: """Create the content for a function.""" - short_name = item["full_name"].split(".")[-1] full_name = item["full_name"] show_annotations = self.show_annotations(item) @@ -189,12 +199,12 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: doc_lines = child.get('doc', '').strip().split('\n') if doc_lines and doc_lines[0]: # Get first paragraph (until empty line or end) - doc_summary = [] + class_doc_summary: list[str] = [] for line in doc_lines: if not line.strip(): break - doc_summary.append(line.strip()) - description = ' '.join(doc_summary) if doc_summary else "None" + class_doc_summary.append(line.strip()) + description = ' '.join(class_doc_summary) if class_doc_summary else "None" else: description = "None" # Escape the description for Fern compatibility @@ -218,12 +228,12 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: doc_lines = child.get('doc', '').strip().split('\n') if doc_lines and doc_lines[0]: # Get first paragraph (until empty line or end) - doc_summary = [] + func_doc_summary: list[str] = [] for line in doc_lines: if not line.strip(): break - doc_summary.append(line.strip()) - description = ' '.join(doc_summary) if doc_summary else "None" + func_doc_summary.append(line.strip()) + description = ' '.join(func_doc_summary) if func_doc_summary else "None" else: description = "None" # Escape the description for Fern compatibility @@ -269,8 +279,6 @@ def render_class(self, item: ItemData) -> t.Iterable[str]: ) sig += f"({args})" - # Class signature in code block (no header - code block IS the header) - full_name = item["full_name"] yield "```python" if constructor and "args" in constructor and args.strip(): yield f"class {item['full_name']}({args})" @@ -323,8 +331,6 @@ def render_class(self, item: ItemData) -> t.Iterable[str]: ): continue - # Render each member with short names in code blocks - child_item = self.get_item(child["full_name"]) child_lines = list(self.render_item(child["full_name"])) for line in child_lines: @@ -343,9 +349,6 @@ def render_exception(self, item: ItemData) -> t.Iterable[str]: def render_property(self, item: ItemData) -> t.Iterable[str]: """Create the content for a property.""" - short_name = item["full_name"].split(".")[-1] - - # Property signature in code block (no header - code block IS the header) full_name = item["full_name"] yield "```python" if item.get("return_annotation"): @@ -361,10 +364,7 @@ def render_property(self, item: ItemData) -> t.Iterable[str]: # Show decorators if any properties = item.get("properties", []) if properties: - decorator_list = [] - for prop in ("abstractmethod", "classmethod"): - if prop in properties: - decorator_list.append(f"`@{prop}`") + decorator_list = [f"`@{prop}`" for prop in ("abstractmethod", "classmethod") if prop in properties] if decorator_list: content_lines.append(f"**Decorators**: {', '.join(decorator_list)}") content_lines.append("") @@ -413,7 +413,7 @@ def render_data(self, item: ItemData) -> t.Iterable[str]: # Handle Jinja templates like MyST does - use for complex templates if self._contains_jinja_template(value_str): if len(value_str.splitlines()) > 1 or len(value_str) > 100: - content_lines.append(f"**Value**: ``") + content_lines.append("**Value**: ``") else: # Short templates - wrap in code block content_lines.append("**Value**:") @@ -426,7 +426,7 @@ def render_data(self, item: ItemData) -> t.Iterable[str]: content_lines.append(f"**Value**: `{escaped_value}`") else: # Show None values explicitly like in HTML output - content_lines.append(f"**Value**: `None`") + content_lines.append("**Value**: `None`") if self.show_docstring(item): if content_lines: @@ -472,7 +472,7 @@ def _format_docstring_sections(self, docstring: str, item: ItemData | None = Non lines = docstring.strip().split('\n') # Parse into sections - sections = {"description": [], "parameters": [], "returns": [], "raises": []} + sections: dict[str, list[str]] = {"description": [], "parameters": [], "returns": [], "raises": []} current_section = "description" i = 0 @@ -520,10 +520,7 @@ def _format_docstring_sections(self, docstring: str, item: ItemData | None = Non # Output formatted sections # Description first if sections["description"]: - desc_lines = [] - for line in sections["description"]: - desc_lines.append(line) - desc_text = '\n'.join(desc_lines).strip() + desc_text = '\n'.join(sections["description"]).strip() if desc_text: yield self._escape_fern_content(desc_text) yield "" @@ -535,12 +532,13 @@ def _format_docstring_sections(self, docstring: str, item: ItemData | None = Non # If we have function item data, use ParamField components if item and item.get("args"): # Build parameter info map from function signature - param_info = {} - for prefix, name, annotation, default in item["args"]: - param_info[name] = { - "type": annotation, - "default": default - } + param_info: dict[str, dict[str, t.Any]] = {} + for _prefix, name, annotation, default in item["args"]: # type: ignore[assignment] + if name: + param_info[name] = { + "type": annotation, + "default": default + } # Render each parameter as ParamField for line in sections["parameters"]: @@ -611,7 +609,7 @@ def _format_docstring_sections(self, docstring: str, item: ItemData | None = Non yield self._escape_fern_content(line.strip()) yield "" - def _format_args_multiline(self, args_info, include_annotations: bool = True, ignore_self: str | None = None) -> str: + def _format_args_multiline(self, args_info: t.Any, include_annotations: bool = True, ignore_self: str | None = None) -> str: """Format function arguments with newlines for better readability.""" if not args_info: return "" @@ -670,13 +668,13 @@ def _format_fern_callouts(self, line: str) -> str: # Convert NOTE: to Fern Note component note_pattern = r'^(\s*)(NOTE:\s*)(.*)' if match := re.match(note_pattern, line, re.IGNORECASE): - indent, prefix, content = match.groups() + indent, _prefix, content = match.groups() return f"{indent} {content.strip()} " # Convert WARNING: to Fern Warning component warning_pattern = r'^(\s*)(WARNING:\s*)(.*)' if match := re.match(warning_pattern, line, re.IGNORECASE): - indent, prefix, content = match.groups() + indent, _prefix, content = match.groups() return f"{indent} {content.strip()} " return line @@ -692,8 +690,8 @@ def _escape_fern_content(self, content: str) -> str: # First, find and temporarily replace HTML-like tags (including those with braces) # Pattern matches: , <{tag}>, <{answer_tag}>, , etc. tag_pattern = r'<[^<>]*(?:\\?\{[^}]*\\?\}[^<>]*)*[^<>]*>' - tags = [] - def replace_tag(match): + tags: list[str] = [] + def replace_tag(match: t.Any) -> str: tag = match.group(0) placeholder = f"__FERN_TAG_{len(tags)}__" tags.append(tag) @@ -711,4 +709,146 @@ def replace_tag(match): escaped_tag = tag.replace('{', '\\{').replace('}', '\\}') escaped_content = escaped_content.replace(placeholder, f'`{escaped_tag}`') - return escaped_content \ No newline at end of file + return escaped_content + + def _generate_slug(self, full_name: str) -> str: + """Generate a slug for a page from its full name. + + Converts dots and underscores to hyphens for URL-friendly slugs. + + :param full_name: The fully qualified name + :return: The slug + """ + return full_name.replace('.', '-').replace('_', '-') + + def _track_navigation_item(self, item: ItemData) -> None: + """Track an item for navigation generation. + + :param item: The item to track (should be a package or module) + """ + full_name = item["full_name"] + type_ = item["type"] + + doc = item.get('doc', '').strip() + description = doc.split('\n')[0] if doc else "" + + nav_item = { + "full_name": full_name, + "type": type_, + "description": description, + "file_path": self._get_file_path_for_item(full_name), + "slug": self._generate_slug(full_name), + } + self._navigation_items.append(nav_item) + + def _get_file_path_for_item(self, full_name: str) -> str: + """Get the relative file path for an item. + + This generates the path that will be used in the navigation YAML. + For example: 'my_package.submodule' -> './pages/api-reference/my_package.submodule.md' + + :param full_name: The fully qualified name of the item + :return: The relative file path + """ + # Convert dots to path separators and add extension + return f"./pages/api-reference/{full_name}{self.EXTENSION}" + + def build_navigation_tree(self) -> list[dict[str, t.Any]]: + """Build a hierarchical navigation tree from tracked items. + + This creates a nested structure that matches Fern's navigation format: + - Top-level packages become sections + - Subpackages and modules become nested sections or pages + + :return: A list of navigation items in Fern format + """ + if not self._navigation_items: + return [] + + tree: dict[str, dict[str, t.Any]] = {} + + for item in self._navigation_items: + full_name = item["full_name"] + parts = full_name.split(".") + + current = tree + for i, part in enumerate(parts): + if part not in current: + current[part] = { + "_item": None, + "_children": {} + } + + if i == len(parts) - 1: + current[part]["_item"] = item + + current = current[part]["_children"] + + # Convert tree to Fern navigation format + def tree_to_nav(node_dict: dict[str, dict[str, t.Any]], parent_name: str = "") -> list[dict[str, t.Any]]: + """Convert tree structure to Fern navigation format.""" + nav_items = [] + + for name in sorted(node_dict.keys()): + node = node_dict[name] + item = node["_item"] + children = node["_children"] + + if item is None: + continue + + full_name = item["full_name"] + short_name = full_name.split(".")[-1] + + if children: + section_item = { + "section": short_name, + "contents": [] + } + + section_item["contents"].append({ + "page": "Overview", + "slug": item["slug"] + }) + + child_nav = tree_to_nav(children, full_name) + section_item["contents"].extend(child_nav) + + nav_items.append(section_item) + else: + page_item = { + "page": short_name, + "slug": item["slug"] + } + nav_items.append(page_item) + + return nav_items + + return tree_to_nav(tree) + + def generate_navigation_yaml(self) -> str: + """Generate a YAML string for Fern navigation. + + This creates a navigation structure that can be saved to a file + and included in a Fern docs.yml file. + + :return: YAML string representing the navigation structure + """ + import yaml + + nav_tree = self.build_navigation_tree() + + if not nav_tree: + return "" + + navigation_structure = { + "navigation": nav_tree + } + + # Generate YAML with proper formatting + return yaml.dump( + navigation_structure, + default_flow_style=False, + sort_keys=False, + allow_unicode=True + ) diff --git a/tests/test_render/test_basic_fern_.md b/tests/test_render/test_basic_fern_.md index 6d4d9fe..9e57f1d 100644 --- a/tests/test_render/test_basic_fern_.md +++ b/tests/test_render/test_basic_fern_.md @@ -1,73 +1,93 @@ -`package` +--- +layout: overview +slug: package +--- + +# package This is a test package. +## Subpackages + +- **[`a`](a)** - This is a test module. + ## Module Contents ### Classes -[`Class`](#class) | This is a class. +| Name | Description | +|------|-------------| +| [`Class`](#packageclass) | This is a class. | ### Functions -[`func`](#func) | This is a function. +| Name | Description | +|------|-------------| +| [`func`](#packagefunc) | This is a function. | ### Data `__all__` `p` -## API +### API + +```python +package.__all__ +``` -### __all__ **Value**: `['p', 'a1', 'alias']` +```python +package.p +``` -### p **Value**: `1` p can be documented here. -## func ```python -def func(a: str, b: int) -> package.a.c.ac1 +package.func( + a: str, b: int +) -> package.a.c.ac1 ``` This is a function. -## Class - ```python -class Class +class package.Class ``` This is a class. -### x -**Type**: `int` +```python +x: int +``` + **Value**: `1` x can be documented here. -### method ```python -def method(a: str, b: int) -> ... +method( + a: str, b: int +) -> ... ``` This is a method. -### prop: `package.a.c.ac1 | None` +```python +prop: package.a.c.ac1 | None +``` This is a property. -### Nested - ```python class Nested ``` diff --git a/tests/test_render/test_config_options_fern_.md b/tests/test_render/test_config_options_fern_.md new file mode 100644 index 0000000..c453820 --- /dev/null +++ b/tests/test_render/test_config_options_fern_.md @@ -0,0 +1,8 @@ +```python +package.func( + a: str, b: int +) -> package.a.c.ac1 +``` + +This is a function. + From c889953ee499db1c467f2486f6e3284a3f62a75f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:55:41 +0000 Subject: [PATCH 2/9] Remove slug from frontmatter and navigation YAML - Remove slug field from frontmatter (Fern auto-generates from path) - Update navigation YAML to use path instead of slug - Remove _generate_slug method (no longer needed) - Update test regression files to match new format - All tests passing, ruff and mypy clean Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/render/fern_.py | 21 ++++----------------- tests/test_render/test_basic_fern_.md | 1 - 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index 6399c6b..52afa2e 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -40,8 +40,6 @@ def render_item(self, full_name: str) -> t.Iterable[str]: if type_ in ("package", "module"): yield "---" yield "layout: overview" - slug = self._generate_slug(full_name) - yield f"slug: {slug}" yield "---" yield "" @@ -711,16 +709,6 @@ def replace_tag(match: t.Any) -> str: return escaped_content - def _generate_slug(self, full_name: str) -> str: - """Generate a slug for a page from its full name. - - Converts dots and underscores to hyphens for URL-friendly slugs. - - :param full_name: The fully qualified name - :return: The slug - """ - return full_name.replace('.', '-').replace('_', '-') - def _track_navigation_item(self, item: ItemData) -> None: """Track an item for navigation generation. @@ -737,7 +725,6 @@ def _track_navigation_item(self, item: ItemData) -> None: "type": type_, "description": description, "file_path": self._get_file_path_for_item(full_name), - "slug": self._generate_slug(full_name), } self._navigation_items.append(nav_item) @@ -807,8 +794,8 @@ def tree_to_nav(node_dict: dict[str, dict[str, t.Any]], parent_name: str = "") - } section_item["contents"].append({ - "page": "Overview", - "slug": item["slug"] + "page": full_name, + "path": item["file_path"] }) child_nav = tree_to_nav(children, full_name) @@ -817,8 +804,8 @@ def tree_to_nav(node_dict: dict[str, dict[str, t.Any]], parent_name: str = "") - nav_items.append(section_item) else: page_item = { - "page": short_name, - "slug": item["slug"] + "page": full_name, + "path": item["file_path"] } nav_items.append(page_item) diff --git a/tests/test_render/test_basic_fern_.md b/tests/test_render/test_basic_fern_.md index 9e57f1d..34631d1 100644 --- a/tests/test_render/test_basic_fern_.md +++ b/tests/test_render/test_basic_fern_.md @@ -1,6 +1,5 @@ --- layout: overview -slug: package --- # package From a73bd8414bd7ef5896641f1c225d012c3b320be9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:23:30 +0000 Subject: [PATCH 3/9] Fix file path generation to use hyphenated filenames - Convert dots and underscores to hyphens in file paths - Matches actual markdown filename format (e.g., nemo-rl-experience.md) - Navigation YAML now correctly references the generated markdown files - Tests passing, ruff and mypy clean Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/render/fern_.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index 52afa2e..cfe1949 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -732,13 +732,15 @@ def _get_file_path_for_item(self, full_name: str) -> str: """Get the relative file path for an item. This generates the path that will be used in the navigation YAML. - For example: 'my_package.submodule' -> './pages/api-reference/my_package.submodule.md' + Converts Python module names to hyphenated filenames. + For example: 'my_package.submodule' -> './my-package-submodule.md' :param full_name: The fully qualified name of the item :return: The relative file path """ - # Convert dots to path separators and add extension - return f"./pages/api-reference/{full_name}{self.EXTENSION}" + # Convert dots and underscores to hyphens for filename + filename = full_name.replace('.', '-').replace('_', '-') + return f"./{filename}{self.EXTENSION}" def build_navigation_tree(self) -> list[dict[str, t.Any]]: """Build a hierarchical navigation tree from tracked items. From a0d75f49c8f2db1ca9b971df27c90677443c8dd1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:35:53 +0000 Subject: [PATCH 4/9] Ensure all cross-page links use consistent hyphenated format - Updated subpackage links to use full hyphenated path - Simplified submodule links to always use hyphenated format - All cross-page links now match file slugs (e.g., package-a) - Within-page anchors still use underscores for compatibility - Tests updated and passing Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/render/fern_.py | 20 +++++--------------- tests/test_render/test_basic_fern_.md | 2 +- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index cfe1949..fcb0120 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -145,8 +145,8 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["package"]: name = child["full_name"].split(".")[-1] - # Create simple relative link using just the child name - link_path = name + # Convert to hyphenated format for cross-page link (matches file slug) + link_path = child["full_name"].replace('.', '-').replace('_', '-') doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." @@ -158,22 +158,12 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["module"]: name = child["full_name"].split(".")[-1] - # Replace underscores with dashes in display text for better readability - display_name = name.replace('_', '-') - - # Create contextual link based on current page - current_parts = item["full_name"].split(".") - if len(current_parts) == 1: - # On root page - use simple name - link_path = display_name - else: - # On subpackage page - use full filename (convert dots and underscores to dashes) - link_path = child["full_name"].replace('.', '-').replace('_', '-') - + # Convert to hyphenated format for cross-page link (matches file slug) + link_path = child["full_name"].replace('.', '-').replace('_', '-') doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." - yield f"- **[`{display_name}`]({link_path})** - {doc_summary}" if doc_summary else f"- **[`{display_name}`]({link_path})**" + yield f"- **[`{name}`]({link_path})** - {doc_summary}" if doc_summary else f"- **[`{name}`]({link_path})**" yield "" # Show Module Contents summary if we have actual content (not just submodules) diff --git a/tests/test_render/test_basic_fern_.md b/tests/test_render/test_basic_fern_.md index 34631d1..e6360a4 100644 --- a/tests/test_render/test_basic_fern_.md +++ b/tests/test_render/test_basic_fern_.md @@ -8,7 +8,7 @@ This is a test package. ## Subpackages -- **[`a`](a)** - This is a test module. +- **[`a`](package-a)** - This is a test module. ## Module Contents From 5a049462d84b6530754e8e8faf17a10baad2c442 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:43:11 +0000 Subject: [PATCH 5/9] Fix navigation paths to match actual markdown filenames - Filenames use Python module names directly (e.g., package.submodule.md) - Navigation paths now match: ./package.submodule.md - Cross-page links use full module names (e.g., package.submodule) - Tests updated and passing, ruff and mypy clean Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/render/fern_.py | 14 +++++--------- tests/test_render/test_basic_fern_.md | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index fcb0120..1012871 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -145,8 +145,7 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["package"]: name = child["full_name"].split(".")[-1] - # Convert to hyphenated format for cross-page link (matches file slug) - link_path = child["full_name"].replace('.', '-').replace('_', '-') + link_path = child["full_name"] doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." @@ -158,8 +157,7 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["module"]: name = child["full_name"].split(".")[-1] - # Convert to hyphenated format for cross-page link (matches file slug) - link_path = child["full_name"].replace('.', '-').replace('_', '-') + link_path = child["full_name"] doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." @@ -722,15 +720,13 @@ def _get_file_path_for_item(self, full_name: str) -> str: """Get the relative file path for an item. This generates the path that will be used in the navigation YAML. - Converts Python module names to hyphenated filenames. - For example: 'my_package.submodule' -> './my-package-submodule.md' + Uses the Python module name directly as the filename. + For example: 'my_package.submodule' -> './my_package.submodule.md' :param full_name: The fully qualified name of the item :return: The relative file path """ - # Convert dots and underscores to hyphens for filename - filename = full_name.replace('.', '-').replace('_', '-') - return f"./{filename}{self.EXTENSION}" + return f"./{full_name}{self.EXTENSION}" def build_navigation_tree(self) -> list[dict[str, t.Any]]: """Build a hierarchical navigation tree from tracked items. diff --git a/tests/test_render/test_basic_fern_.md b/tests/test_render/test_basic_fern_.md index e6360a4..569af32 100644 --- a/tests/test_render/test_basic_fern_.md +++ b/tests/test_render/test_basic_fern_.md @@ -8,7 +8,7 @@ This is a test package. ## Subpackages -- **[`a`](package-a)** - This is a test module. +- **[`a`](package.a)** - This is a test module. ## Module Contents From 40e9937b2651ee67dba856fa1a3527b30fe16b2a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:49:47 +0000 Subject: [PATCH 6/9] Convert dots and underscores to hyphens in filenames - Filenames now use hyphens: package-a.md, package-a-c.md - Navigation paths match: ./package-a.md, ./package-a-c.md - Cross-page links use hyphens: package-a - Page names keep original format: package.a (for display) - CLI updated to write hyphenated filenames for Fern renderer - All navigation paths verified to match actual files - Tests passing, ruff and mypy clean Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/cli.py | 8 +++++++- src/autodoc2/render/fern_.py | 11 ++++++----- tests/test_render/test_basic_fern_.md | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/autodoc2/cli.py b/src/autodoc2/cli.py index dd79e1f..5b4271f 100644 --- a/src/autodoc2/cli.py +++ b/src/autodoc2/cli.py @@ -288,7 +288,13 @@ def _warn(msg: str, type_: WarningSubtypes) -> None: content = "\n".join( render_class(db, config, warn=_warn).render_item(mod_name) ) - out_path = output / (mod_name + render_class.EXTENSION) + + if renderer == "fern": + filename = mod_name.replace('.', '-').replace('_', '-') + out_path = output / (filename + render_class.EXTENSION) + else: + out_path = output / (mod_name + render_class.EXTENSION) + paths.append(out_path) if out_path.exists() and out_path.read_text("utf8") == content: continue diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index 1012871..fa883ea 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -145,7 +145,7 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["package"]: name = child["full_name"].split(".")[-1] - link_path = child["full_name"] + link_path = child["full_name"].replace('.', '-').replace('_', '-') doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." @@ -157,7 +157,7 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["module"]: name = child["full_name"].split(".")[-1] - link_path = child["full_name"] + link_path = child["full_name"].replace('.', '-').replace('_', '-') doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." @@ -720,13 +720,14 @@ def _get_file_path_for_item(self, full_name: str) -> str: """Get the relative file path for an item. This generates the path that will be used in the navigation YAML. - Uses the Python module name directly as the filename. - For example: 'my_package.submodule' -> './my_package.submodule.md' + Converts dots and underscores to hyphens for clean URLs. + For example: 'my_package.submodule' -> './my-package-submodule.md' :param full_name: The fully qualified name of the item :return: The relative file path """ - return f"./{full_name}{self.EXTENSION}" + filename = full_name.replace('.', '-').replace('_', '-') + return f"./{filename}{self.EXTENSION}" def build_navigation_tree(self) -> list[dict[str, t.Any]]: """Build a hierarchical navigation tree from tracked items. diff --git a/tests/test_render/test_basic_fern_.md b/tests/test_render/test_basic_fern_.md index 569af32..e6360a4 100644 --- a/tests/test_render/test_basic_fern_.md +++ b/tests/test_render/test_basic_fern_.md @@ -8,7 +8,7 @@ This is a test package. ## Subpackages -- **[`a`](package.a)** - This is a test module. +- **[`a`](package-a)** - This is a test module. ## Module Contents From 0c77c6428ab6c745099e65b5624216d2c987b3a2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:59:16 +0000 Subject: [PATCH 7/9] Convert dots to underscores in filenames (not hyphens) - Filenames now use underscores: package_a.md, package_a_c.md - Navigation paths match: ./package_a.md, ./package_a_c.md - Cross-page links use underscores: package_a, package_a_c - Existing underscores in module names are preserved - CLI updated to convert dots to underscores for Fern renderer - All navigation paths verified to match actual files - Tests passing, ruff and mypy clean Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/cli.py | 2 +- src/autodoc2/render/fern_.py | 10 +++++----- tests/test_render/test_basic_fern_.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/autodoc2/cli.py b/src/autodoc2/cli.py index 5b4271f..8b39c0f 100644 --- a/src/autodoc2/cli.py +++ b/src/autodoc2/cli.py @@ -290,7 +290,7 @@ def _warn(msg: str, type_: WarningSubtypes) -> None: ) if renderer == "fern": - filename = mod_name.replace('.', '-').replace('_', '-') + filename = mod_name.replace('.', '_') out_path = output / (filename + render_class.EXTENSION) else: out_path = output / (mod_name + render_class.EXTENSION) diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index fa883ea..162b1e5 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -145,7 +145,7 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["package"]: name = child["full_name"].split(".")[-1] - link_path = child["full_name"].replace('.', '-').replace('_', '-') + link_path = child["full_name"].replace('.', '_') doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." @@ -157,7 +157,7 @@ def render_package(self, item: ItemData) -> t.Iterable[str]: yield "" for child in children_by_type["module"]: name = child["full_name"].split(".")[-1] - link_path = child["full_name"].replace('.', '-').replace('_', '-') + link_path = child["full_name"].replace('.', '_') doc_summary = child.get('doc', '').split('\n')[0][:80] if child.get('doc') else "" if len(child.get('doc', '')) > 80: doc_summary += "..." @@ -720,13 +720,13 @@ def _get_file_path_for_item(self, full_name: str) -> str: """Get the relative file path for an item. This generates the path that will be used in the navigation YAML. - Converts dots and underscores to hyphens for clean URLs. - For example: 'my_package.submodule' -> './my-package-submodule.md' + Converts dots to underscores, keeping existing underscores. + For example: 'my_package.submodule' -> './my_package_submodule.md' :param full_name: The fully qualified name of the item :return: The relative file path """ - filename = full_name.replace('.', '-').replace('_', '-') + filename = full_name.replace('.', '_') return f"./{filename}{self.EXTENSION}" def build_navigation_tree(self) -> list[dict[str, t.Any]]: diff --git a/tests/test_render/test_basic_fern_.md b/tests/test_render/test_basic_fern_.md index e6360a4..e46c423 100644 --- a/tests/test_render/test_basic_fern_.md +++ b/tests/test_render/test_basic_fern_.md @@ -8,7 +8,7 @@ This is a test package. ## Subpackages -- **[`a`](package-a)** - This is a test module. +- **[`a`](package_a)** - This is a test module. ## Module Contents From 577e6cd82eb36990097f781688ea76956e12c600 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:14:29 +0000 Subject: [PATCH 8/9] Use full module names for section titles in navigation - Section names now use full module path: 'package.a' instead of 'a' - Each section includes its own page as first item in contents - Matches nvidia-nemo pattern where sections have associated pages - Cross-page links verified to work: link 'package_a' -> file 'package_a.md' - Removed unused variable, tests passing, ruff and mypy clean Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/render/fern_.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index 162b1e5..c2d6cdd 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -774,11 +774,10 @@ def tree_to_nav(node_dict: dict[str, dict[str, t.Any]], parent_name: str = "") - continue full_name = item["full_name"] - short_name = full_name.split(".")[-1] if children: section_item = { - "section": short_name, + "section": full_name, "contents": [] } From 9d0fb4156ef41a28c163dfbc5909276608e4fb1d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 20:18:22 +0000 Subject: [PATCH 9/9] Add path field directly to sections instead of nested page - Sections now have path field: section + path + contents - Removed redundant page entry for section's own page - Matches Fern's section-with-path pattern - Example: section 'package.a' has path './package_a.md' directly - Tests passing, ruff and mypy clean Co-Authored-By: ryanstep@buildwithfern.com --- src/autodoc2/render/fern_.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/autodoc2/render/fern_.py b/src/autodoc2/render/fern_.py index c2d6cdd..c56ed09 100644 --- a/src/autodoc2/render/fern_.py +++ b/src/autodoc2/render/fern_.py @@ -778,14 +778,10 @@ def tree_to_nav(node_dict: dict[str, dict[str, t.Any]], parent_name: str = "") - if children: section_item = { "section": full_name, + "path": item["file_path"], "contents": [] } - section_item["contents"].append({ - "page": full_name, - "path": item["file_path"] - }) - child_nav = tree_to_nav(children, full_name) section_item["contents"].extend(child_nav)