Skip to content
Merged
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
29 changes: 29 additions & 0 deletions src/rockgarden/content/loader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Content discovery and loading."""

import fnmatch
import logging
from datetime import date, datetime
from pathlib import Path

Expand All @@ -10,6 +11,8 @@
from rockgarden.content.models import Page
from rockgarden.urls import generate_slug

logger = logging.getLogger(__name__)


def _resolve_frontmatter_date(
metadata: dict, field_names: list[str]
Expand Down Expand Up @@ -105,6 +108,10 @@ def load_page(
slug = custom_slug
else:
slug = path_to_slug(path, source, url_style, ascii_urls)
# Folder note: rewrite slug so downstream index-page logic applies
parts = slug.split("/")
if len(parts) >= 2 and parts[-1] == parts[-2]:
slug = "/".join(parts[:-1]) + "/index"

modified = _resolve_frontmatter_date(metadata, dates_config.modified_date_fields)
if modified is None and dates_config.modified_date_fallback:
Expand Down Expand Up @@ -148,4 +155,26 @@ def load_content(
page = load_page(path, source, dates_config, url_style, ascii_urls)
pages.append(page)

# Resolve conflicts: if both a folder note and index.md exist, index.md wins
index_slugs: dict[str, list[Page]] = {}
for page in pages:
if page.slug.endswith("/index") or page.slug == "index":
index_slugs.setdefault(page.slug, []).append(page)

for _slug, slug_pages in index_slugs.items():
if len(slug_pages) > 1:
explicit = [p for p in slug_pages if p.source_path.name == "index.md"]
folder_notes = [p for p in slug_pages if p.source_path.name != "index.md"]
if explicit and folder_notes:
for fn in folder_notes:
fn.slug = path_to_slug(
fn.source_path, source, url_style, ascii_urls
)
logger.warning(
"Both %s and index.md exist in %s. "
"Using index.md as the folder page.",
fn.source_path.name,
fn.source_path.parent,
)

return pages
8 changes: 8 additions & 0 deletions src/rockgarden/content/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ def _index_page(self, page: Page) -> None:
normalized_alias = unicodedata.normalize("NFC", aliases).lower()
self._by_name[normalized_alias] = page

# Index pages: register under parent folder name as fallback for wiki-links
parts = page.slug.split("/")
if parts[-1] == "index" and len(parts) >= 2:
folder_name = parts[-2]
normalized_folder = unicodedata.normalize("NFC", folder_name).lower()
if normalized_folder not in self._by_name:
self._by_name[normalized_folder] = page

def get_by_slug(self, slug: str) -> Page | None:
"""Look up a page by its slug."""
return self._by_slug.get(slug)
Expand Down
62 changes: 62 additions & 0 deletions tests/test_content_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,65 @@ def test_section_link_clean_urls_disabled(self):

url = store.resolve_link("notes#section")
assert url == "/notes.html#section"


class TestFolderNoteResolution:
"""Test wiki-link resolution for folder notes and index pages."""

def test_index_md_resolves_by_folder_name(self):
"""[[Fairshore]] should resolve to Fairshore/index.md via fallback."""
page = Page(
source_path=Path("Fairshore/index.md"),
slug="fairshore/index",
frontmatter={},
content="",
)
store = ContentStore([page])

url = store.resolve_link("Fairshore")
assert url == "/fairshore/"

def test_index_fallback_no_overwrite(self):
"""Stem-based match takes priority over index fallback."""
explicit_page = Page(
source_path=Path("Fairshore.md"),
slug="fairshore",
frontmatter={},
content="",
)
index_page = Page(
source_path=Path("other/fairshore/index.md"),
slug="other/fairshore/index",
frontmatter={},
content="",
)
store = ContentStore([explicit_page, index_page])

url = store.resolve_link("Fairshore")
assert url == "/fairshore/"

def test_folder_note_resolves_by_name(self):
"""Folder note with rewritten slug still resolves via source_path stem."""
page = Page(
source_path=Path("locations/Fairshore/Fairshore.md"),
slug="locations/fairshore/index",
frontmatter={},
content="",
)
store = ContentStore([page])

url = store.resolve_link("Fairshore")
assert url == "/locations/fairshore/"

def test_index_fallback_case_insensitive(self):
"""Index fallback should be case-insensitive."""
page = Page(
source_path=Path("Fairshore/index.md"),
slug="fairshore/index",
frontmatter={},
content="",
)
store = ContentStore([page])

assert store.resolve_link("fairshore") == "/fairshore/"
assert store.resolve_link("FAIRSHORE") == "/fairshore/"
39 changes: 39 additions & 0 deletions tests/test_folder_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,42 @@ def test_frontmatter_override_wins(self):
blog = next(fi for fi in indexes if fi.slug == "blog/index")
titles = [c.title for c in blog.children]
assert titles == ["Gamma", "Beta", "Alpha"]


class TestFolderNoteFolderIndex:
"""Test folder index generation with folder notes (slug rewritten to index)."""

def test_folder_note_used_as_existing_index(self):
"""Folder note content and title should be used for the folder index."""
pages = [
Page(
source_path=Path("/vault/Fairshore/Fairshore.md"),
slug="fairshore/index",
frontmatter={"title": "City of Fairshore"},
content="A coastal trading city.",
),
make_page("fairshore/the-salty-dog", "The Salty Dog"),
]
indexes = generate_folder_indexes(pages)
fairshore = next(fi for fi in indexes if fi.slug == "fairshore/index")
assert fairshore.title == "City of Fairshore"
assert fairshore.custom_content == "A coastal trading city."

def test_folder_note_not_in_children(self):
"""Folder note should not appear in its own folder's children list."""
pages = [
Page(
source_path=Path("/vault/Fairshore/Fairshore.md"),
slug="fairshore/index",
frontmatter={"title": "City of Fairshore"},
content="",
),
make_page("fairshore/the-salty-dog", "The Salty Dog"),
make_page("fairshore/market-square", "Market Square"),
]
indexes = generate_folder_indexes(pages)
fairshore = next(fi for fi in indexes if fi.slug == "fairshore/index")
child_titles = [c.title for c in fairshore.children]
assert "City of Fairshore" not in child_titles
assert "The Salty Dog" in child_titles
assert "Market Square" in child_titles
96 changes: 96 additions & 0 deletions tests/test_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Tests for content loader folder note detection."""

from pathlib import Path

import pytest

from rockgarden.content.loader import load_content, load_page


@pytest.fixture
def source_dir(tmp_path):
"""Create a temporary source directory."""
return tmp_path / "source"


def _write_page(source: Path, rel_path: str, content: str = "") -> Path:
"""Write a markdown file and return its path."""
path = source / rel_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
return path


class TestFolderNoteSlugRewrite:
def test_basic_folder_note(self, source_dir):
"""Fairshore/Fairshore.md should get slug rewritten to index."""
path = _write_page(source_dir, "Fairshore/Fairshore.md")
page = load_page(path, source_dir)
assert page.slug == "fairshore/index"

def test_source_path_preserved(self, source_dir):
"""Source path should remain the original file path."""
path = _write_page(source_dir, "Fairshore/Fairshore.md")
page = load_page(path, source_dir)
assert page.source_path == path

def test_root_page_not_detected(self, source_dir):
"""A page at the source root should not be treated as a folder note."""
path = _write_page(source_dir, "about.md")
page = load_page(path, source_dir)
assert page.slug == "about"

def test_non_matching_not_rewritten(self, source_dir):
"""A page whose name differs from parent folder is not rewritten."""
path = _write_page(source_dir, "Fairshore/The Salty Dog.md")
page = load_page(path, source_dir)
assert page.slug == "fairshore/the-salty-dog"

def test_custom_slug_overrides(self, source_dir):
"""Custom slug in frontmatter bypasses folder note detection."""
path = _write_page(
source_dir,
"Fairshore/Fairshore.md",
"---\nslug: my-custom-slug\n---\n",
)
page = load_page(path, source_dir)
assert page.slug == "my-custom-slug"

def test_nested_folder_note(self, source_dir):
"""Deeply nested folder note: a/a/a.md -> a/a/index."""
(source_dir / "a" / "a").mkdir(parents=True, exist_ok=True)
path = _write_page(source_dir, "a/a/a.md")
page = load_page(path, source_dir)
assert page.slug == "a/a/index"

def test_index_md_not_rewritten(self, source_dir):
"""An actual index.md should not be double-rewritten."""
path = _write_page(source_dir, "Fairshore/index.md")
page = load_page(path, source_dir)
assert page.slug == "fairshore/index"


class TestFolderNoteConflict:
def test_index_md_wins_over_folder_note(self, source_dir):
"""When both folder note and index.md exist, index.md wins."""
_write_page(source_dir, "Fairshore/Fairshore.md", "folder note content")
_write_page(source_dir, "Fairshore/index.md", "index content")

pages = load_content(source_dir, [])
index_pages = [p for p in pages if p.slug == "fairshore/index"]
assert len(index_pages) == 1
assert index_pages[0].source_path.name == "index.md"

folder_notes = [p for p in pages if p.source_path.name == "Fairshore.md"]
assert len(folder_notes) == 1
assert folder_notes[0].slug == "fairshore/fairshore"

def test_conflict_emits_warning(self, source_dir, caplog):
"""Conflict between folder note and index.md should emit a warning."""
_write_page(source_dir, "Fairshore/Fairshore.md")
_write_page(source_dir, "Fairshore/index.md")

with caplog.at_level("WARNING", logger="rockgarden.content.loader"):
load_content(source_dir, [])
assert "index.md" in caplog.text
assert "folder page" in caplog.text
44 changes: 44 additions & 0 deletions tests/test_nav_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,47 @@ def test_root_not_affected_by_subfolder_override(self):
tree = build_nav_tree(pages, config)
root_labels = [c.label for c in tree.children if not c.is_folder]
assert root_labels == ["alpha", "beta"]


class TestFolderNoteNav:
"""Test nav tree with folder note pages (slug rewritten to index)."""

def test_folder_note_clickable_folder(self):
"""Folder note should make the folder node clickable."""
pages = [
Page(
source_path=Path("/vault/locations/Fairshore/Fairshore.md"),
slug="locations/fairshore/index",
frontmatter={"title": "Fairshore"},
content="",
),
make_page("locations/fairshore/the-salty-dog", "The Salty Dog"),
]
tree = build_nav_tree(pages)

locations = tree.children[0]
assert locations.is_folder
fairshore = locations.children[0]
assert fairshore.is_folder
assert fairshore.index_path == "/locations/fairshore/"

def test_folder_note_not_duplicate_child(self):
"""Folder note should not appear as a child of its own folder."""
pages = [
Page(
source_path=Path("/vault/locations/Fairshore/Fairshore.md"),
slug="locations/fairshore/index",
frontmatter={"title": "Fairshore"},
content="",
),
make_page("locations/fairshore/the-salty-dog", "The Salty Dog"),
make_page("locations/fairshore/market-square", "Market Square"),
]
tree = build_nav_tree(pages)

locations = tree.children[0]
fairshore = locations.children[0]
child_labels = [c.label for c in fairshore.children]
assert "Fairshore" not in child_labels
assert "The Salty Dog" in child_labels
assert "Market Square" in child_labels
Loading