Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 1.2.0 (Unreleased)

- Added support for partial nav inclusion using anchor syntax (`!include path/to/mkdocs.yml#SectionName`)
- Added unique alias generation for anchored includes to prevent naming conflicts

## 1.1.2

- Dropped official support for Python 3.8
Expand Down
23 changes: 23 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
# Caveats / Known Design Decisions

- In an included `mkdocs.yml`, you cannot have `!include`. It is only supported in the root `mkdocs.yml`

- **Anchor-based inclusion alias collision risk**: When using anchor syntax (`!include path/to/mkdocs.yml#SectionName`), the generated alias follows the pattern `{site_name}-{SectionName}`. This can create a collision if you also include a separate `mkdocs.yml` file whose `site_name` exactly matches this generated alias.

**Example collision scenario:**
```yaml
# File A: docs/team-a/mkdocs.yml
site_name: MyDocs
nav:
- Guides:
- Getting Started: getting-started.md

# File B: docs/team-b/mkdocs.yml
site_name: MyDocs-Guides
nav:
- Overview: overview.md

# Root mkdocs.yml - This will cause a collision!
nav:
- Team A Guides: "!include docs/team-a/mkdocs.yml#Guides" # alias: "MyDocs-Guides"
- Team B: "!include docs/team-b/mkdocs.yml" # alias: "MyDocs-Guides"
```

**Workaround:** Ensure your `site_name` values don't match the pattern `{OtherSiteName}-{SectionName}` when using anchor-based includes. The collision will be detected at build time with a clear error message listing all registered aliases.
44 changes: 39 additions & 5 deletions mkdocs_monorepo_plugin/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ def getResolvedPaths(self):
def extractAliasAndPath(absPath):
loader = IncludeNavLoader(self.config, absPath).read()
alias = loader.getAlias()
# Use the actual navPath (without anchor) for directory calculations
actualPath = loader.navPath
docsDir = os.path.join(
loader.rootDir, os.path.dirname(absPath), loader.getDocsDir()
loader.rootDir, os.path.dirname(actualPath), loader.getDocsDir()
)
return [alias, docsDir, os.path.join(loader.rootDir, absPath)]
return [alias, docsDir, os.path.join(loader.rootDir, actualPath)]

resolvedPaths = list(
map(extractAliasAndPath, self.__loadAliasesAndResolvedPaths())
Expand Down Expand Up @@ -172,7 +174,12 @@ def __init__(self, config, navPath, ancestors=None):
self.rootDir = os.path.normpath(
os.path.join(os.getcwd(), config["config_file_path"], "../")
)
self.navPath = navPath
# Parse navPath for anchor (e.g., "path/to/mkdocs.yml#Guides")
if "#" in navPath:
self.navPath, self.navSection = navPath.split("#", 1)
else:
self.navPath = navPath
self.navSection = None
self.absNavPath = os.path.normpath(os.path.join(self.rootDir, self.navPath))
self.navYaml = None
# Track ancestor paths to detect cycles
Expand Down Expand Up @@ -285,15 +292,42 @@ def getDocsDir(self):

def getAlias(self):
alias = self.navYaml["site_name"]

# If a section is specified, append it to the alias to make it unique
if self.navSection:
alias = f"{alias}-{self.navSection}"

regex = "^[a-zA-Z0-9_\.\-/]+$" # noqa: W605

if re.match(regex, alias) is None:
alias = slugify(self.navYaml["site_name"])
alias = slugify(alias)

return alias

def getNav(self):
return self._prependAliasToNavLinks(self.getAlias(), self.navYaml["nav"])
nav = self.navYaml["nav"]

# If a section is specified, extract only that section
if self.navSection:
nav = self._extractNavSection(nav, self.navSection)
if nav is None:
log.critical(
f"[mkdocs-monorepo] Could not find nav section '{self.navSection}' "
f"in {self.absNavPath}"
)
raise SystemExit(1)

return self._prependAliasToNavLinks(self.getAlias(), nav)

def _extractNavSection(self, nav, section_name):
"""Extract a specific section from the nav by its name."""
for item in nav:
if isinstance(item, dict):
key = list(item.keys())[0]
if key == section_name:
# Return the contents of this section
return item[key]
return None

def _prependAliasToNavLinks(self, alias, nav):
for index, item in enumerate(nav):
Expand Down
147 changes: 147 additions & 0 deletions mkdocs_monorepo_plugin/tests/test_plugin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
#!/usr/bin/env python

import unittest
import os
import tempfile

from mkdocs_monorepo_plugin import plugin as p
from mkdocs_monorepo_plugin.parser import IncludeNavLoader


class MockServer:
Expand Down Expand Up @@ -40,3 +43,147 @@ def test_plugin_on_serve(self):
server = MockServer()
plugin.on_serve(server, {})
self.assertSetEqual(set(server.watched), {"docs"})


class TestAnchorBasedPartialNavInclusion(unittest.TestCase):
"""Tests for anchor-based partial navigation inclusion feature."""

def setUp(self):
"""Create a temporary directory with test mkdocs.yml files."""
self.test_dir = tempfile.mkdtemp()
self.mkdocs_file = os.path.join(self.test_dir, "mkdocs.yml")

# Create a test mkdocs.yml with multiple nav sections
with open(self.mkdocs_file, 'w') as f:
f.write("""site_name: TestSite
docs_dir: docs
nav:
- Home: index.md
- Guides:
- Getting Started: guides/getting-started.md
- Advanced: guides/advanced.md
- Reference:
- API: reference/api.md
- CLI: reference/cli.md
""")

# Create docs directory
os.makedirs(os.path.join(self.test_dir, "docs"))

def tearDown(self):
"""Clean up temporary directory."""
import shutil
shutil.rmtree(self.test_dir)

def test_anchor_parsing(self):
"""Test that anchors are correctly parsed from include paths."""
config = {"config_file_path": self.mkdocs_file}

# Test with anchor
loader = IncludeNavLoader(config, "mkdocs.yml#Guides")
self.assertEqual(loader.navPath, "mkdocs.yml")
self.assertEqual(loader.navSection, "Guides")

# Test without anchor
loader_no_anchor = IncludeNavLoader(config, "mkdocs.yml")
self.assertEqual(loader_no_anchor.navPath, "mkdocs.yml")
self.assertIsNone(loader_no_anchor.navSection)

def test_extract_nav_section(self):
"""Test that specific nav sections can be extracted."""
config = {"config_file_path": self.mkdocs_file}
loader = IncludeNavLoader(config, "mkdocs.yml#Guides")
loader.read()

# The extracted nav should only contain the Guides section content
nav = loader.getNav()

# Check that we got a list (the content of the Guides section)
self.assertIsInstance(nav, list)

# The nav should contain items from the Guides section
self.assertTrue(len(nav) > 0)

def test_unique_alias_with_section(self):
"""Test that aliases are unique when sections are specified."""
config = {"config_file_path": self.mkdocs_file}

# Load with section anchor
loader_with_section = IncludeNavLoader(config, "mkdocs.yml#Guides")
loader_with_section.read()
alias_with_section = loader_with_section.getAlias()

# Load without section anchor
loader_without_section = IncludeNavLoader(config, "mkdocs.yml")
loader_without_section.read()
alias_without_section = loader_without_section.getAlias()

# Aliases should be different
self.assertNotEqual(alias_with_section, alias_without_section)
self.assertIn("Guides", alias_with_section)

def test_section_not_found(self):
"""Test that an error is raised when the specified section doesn't exist."""
config = {"config_file_path": self.mkdocs_file}
loader = IncludeNavLoader(config, "mkdocs.yml#NonExistentSection")

with self.assertRaises(SystemExit):
loader.read()
loader.getNav()

def test_alias_collision_detection(self):
"""Test that alias collisions are properly detected.

Known limitation: If a site_name matches another site's generated
alias (site_name-SectionName), a collision occurs.

Example collision scenario:
- Site A: site_name="TestSite" with #Guides -> alias="TestSite-Guides"
- Site B: site_name="TestSite-Guides" (no anchor) -> alias="TestSite-Guides"

This test verifies that such collisions are detected and raise an error.
"""
# Create a second mkdocs file with a site_name that collides with
# the generated alias from the first file
collision_file = os.path.join(self.test_dir, "collision.yml")
with open(collision_file, 'w') as f:
f.write("""site_name: TestSite-Guides
docs_dir: docs
nav:
- Home: index.md
""")

# Set up config with both includes - one with anchor, one without
root_config = {
"config_file_path": os.path.join(self.test_dir, "root.yml"),
"docs_dir": os.path.join(self.test_dir, "docs"),
"nav": [
{"Section1": "!include mkdocs.yml#Guides"},
{"Section2": "!include collision.yml"}
]
}

# Create root mkdocs.yml
with open(root_config["config_file_path"], 'w') as f:
f.write("""site_name: Root
docs_dir: docs
nav:
- Section1: "!include mkdocs.yml#Guides"
- Section2: "!include collision.yml"
""")

# Verify the aliases would collide
config1 = {"config_file_path": self.mkdocs_file}
loader1 = IncludeNavLoader(config1, "mkdocs.yml#Guides")
loader1.read()
alias1 = loader1.getAlias()

config2 = {"config_file_path": collision_file}
loader2 = IncludeNavLoader(config2, "collision.yml")
loader2.read()
alias2 = loader2.getAlias()

# Both should produce "TestSite-Guides"
self.assertEqual(alias1, "TestSite-Guides")
self.assertEqual(alias2, "TestSite-Guides")
self.assertEqual(alias1, alias2) # Collision confirmed