diff --git a/README.md b/README.md index 40f9193..64475b6 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ rockgarden serve # preview locally **Navigation & discovery:** -- Auto-generated sidebar, breadcrumbs, folder index pages +- Auto-generated sidebar, breadcrumbs, folder index pages (with optional `_folder.md` for per-folder nav metadata) - Per-page table of contents - Backlinks - Client-side full-text search diff --git a/docs/Configuration.md b/docs/Configuration.md index 8d3634f..fc9ff6f 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -44,6 +44,23 @@ All styles strip the `.md` extension and preserve directory structure. Per-page url_style = "preserve-case" ``` +### Folder URLs + +A folder renders at `/folder/` when it contains either: + +- `index.md` — the canonical form +- a folder-note: a file matching the folder name, e.g. `attend/attend.md` (Obsidian folder-note convention). The slug is rewritten internally to `attend/index`. + +Both forms produce the same URL (`/attend/`) and are interchangeable. When both `index.md` and a folder-note coexist, `index.md` wins and a warning is logged. + +To place a page literally at `/folder/folder/` (bypassing the folder-note rewrite), set an explicit `slug` in the page's frontmatter: + +```yaml +--- +slug: attend/attend +--- +``` + ## `[build]` | Field | Type | Default | Description | @@ -88,7 +105,7 @@ Theme-specific display and rendering options. These are supported by the default ### Per-folder sort overrides -Override `sort` and `reverse` for specific folders via config or frontmatter. +Override `sort` and `reverse` for specific folders via config or per-folder metadata file. **In `rockgarden.toml`:** @@ -98,7 +115,7 @@ sort = "date" reverse = true ``` -**In a folder's `index.md` frontmatter** (takes priority over config): +**In a folder's `_folder.md` frontmatter** (takes priority over config): ```yaml --- @@ -107,10 +124,27 @@ sort_reverse: true --- ``` -Priority: frontmatter > `[nav.overrides.]` > global `[nav]` defaults. +Priority: `_folder.md` > `[nav.overrides.]` > global `[nav]` defaults. The `"date"` sort strategy orders by file modified time and is only available on folder index pages. In the nav sidebar, `"date"` falls back to `"files-first"`. +## Folder Metadata (`_folder.md`) + +An optional `_folder.md` file inside any content folder holds folder-level metadata. Only the frontmatter is consumed — any body content is ignored. The file is not published as a page and is not indexed for wiki-link resolution. + +Supported fields: + +| Field | Type | Description | +| -------------- | ------ | ------------------------------------------------------------------------- | +| `nav_order` | `int` | Pin the folder's position in its parent nav (lower = higher) | +| `label` | `str` | Override the folder's display label in nav and breadcrumbs | +| `sort` | `str` | Child sort strategy (`alphabetical`, `files-first`, `folders-first`, `date`) | +| `sort_reverse` | `bool` | Reverse the child sort order | +| `unlisted` | `bool` | Hide the folder (and its descendants) from navigation entirely | +| `show_index` | `bool` | Render the auto-generated folder listing at `/folder/` instead of the folder's `index.md` page. If an `index.md` also exists, its body is used as prose above the auto-listing. | + +`_folder.md` is strictly optional — folders work without one, using defaults. + ## `[feed]` Atom feed generation. Requires `site.base_url` to be set. @@ -205,8 +239,7 @@ Per-page options set in YAML frontmatter: | `aliases` | `str` or `list` | Alternative names for wikilink resolution | | `author` | `str` | Page author (used in Atom feed, overrides site-level feed author) | | `subtitle` | `str` | Subtitle shown below the page title, in folder indexes, and tag indexes | -| `show_index` | `bool` | For `index.md` files: render page content + auto-generated folder listing | -| `unlisted` | `bool` | Hide page from sidebar navigation and folder indexes (still accessible by URL) | +| `unlisted` | `bool` | Hide page from sidebar navigation and folder indexes (still accessible by URL). On an `index.md` / folder-note, the page still renders at `/folder/` but no nav entry links to it; the folder itself remains visible. To hide an entire folder, set `unlisted: true` in a `_folder.md`. | ## CLI Overrides diff --git a/site/Configuration.md b/site/Configuration.md index 16ce238..f07d512 100644 --- a/site/Configuration.md +++ b/site/Configuration.md @@ -76,21 +76,39 @@ title: Page Title # Used in nav and slug: custom-slug # Override generated URL slug nav_order: 1 # Pin position in nav (lower = first) tags: [doc, guide] # Shown in folder listings -show_index: true # For index.md: add folder listing +unlisted: true # Hide from nav (URL still works) --- ``` -### Folder Index Pages +`unlisted: true` on an `index.md` or folder-note keeps the page at `/folder/` reachable by URL but removes the nav link to it; the folder itself stays in nav with its children. + +### Folder Metadata (`_folder.md`) + +Per-folder options live in an optional `_folder.md` file inside the folder. Frontmatter only — body is ignored. The file is not published as a page. + +```yaml +--- +nav_order: 2 # Pin folder in parent nav +label: My Folder # Folder display label override +sort: alphabetical # Child sort strategy +sort_reverse: false +unlisted: true # Hide folder (and descendants) from nav +show_index: true # Use auto-listing at /folder/ even if index.md exists +--- +``` -The `show_index` option controls how `index.md` files are rendered: +See [[Navigation]] for full details. + +### Folder Index Pages -| Scenario | Result | -|----------|--------| -| No `index.md` in folder | Auto-generated folder listing | -| `index.md` exists (default) | Renders as normal page | -| `index.md` with `show_index: true` | Page content + folder listing | +A folder is served at `/folder/` when it contains any of: -This lets you write custom landing pages for folders while still optionally including the file listing. +| Scenario | Result at `/folder/` | +|----------|----------------------| +| Nothing | Auto-generated folder listing | +| `index.md` | `index.md` renders as a normal page | +| Folder-note (`folder/folder.md`) | Equivalent to `index.md` — same URL | +| `_folder.md` with `show_index: true` and `index.md` | Auto-listing with `index.md` body as prose prefix | ## URL Slugs @@ -116,7 +134,7 @@ slug: quickstart --- ``` -This produces `/quickstart/` instead of `/getting-started/`. +This produces `/quickstart/` instead of `/getting-started/`. Use this as the escape hatch to place a page literally at `/folder/folder/` (bypassing the folder-note rewrite). ## CLI Overrides diff --git a/site/Navigation.md b/site/Navigation.md index f83fbc9..78984a8 100644 --- a/site/Navigation.md +++ b/site/Navigation.md @@ -38,8 +38,9 @@ sort = "alphabetical" # mixed files and folders Folder display names resolve in order: 1. Config `labels` override -2. Folder's `index.md` frontmatter title -3. Folder name (titlecased) +2. `label` field in the folder's `_folder.md` +3. Folder's `index.md` / folder-note title +4. Folder name (titlecased) ```toml [nav] @@ -65,7 +66,7 @@ Each page shows a breadcrumb trail from root to current location. Folders without an `index.md` automatically get a generated index page listing their contents. -If you create `folder/index.md`, it renders as a normal page: +If you create `folder/index.md`, it renders as a normal page at `/folder/`: ```yaml --- @@ -75,15 +76,35 @@ title: My Folder This is a custom landing page for the folder. ``` -To show both your content and the folder listing, add `show_index: true`: +Obsidian's folder-note convention also works: a file matching the folder name (`folder/folder.md`) renders at the same URL (`/folder/`). Both forms are equivalent. + +### Folder Metadata (`_folder.md`) + +Per-folder options live in an optional `_folder.md` file inside the folder. The file is frontmatter-only — any body is ignored — and is never published as a page. ```yaml --- -title: My Folder -show_index: true +nav_order: 2 +label: "My Folder" +sort: alphabetical +sort_reverse: false --- - -Custom intro text above the listing. ``` +Supported fields: + +- `nav_order` — pin the folder's position in its parent nav +- `label` — override the folder's display label +- `sort` / `sort_reverse` — child sort strategy (per-folder override of config) +- `unlisted` — hide the folder (and all its descendants) from nav +- `show_index` — render the auto-generated listing at `/folder/` instead of the `index.md` page (if both exist, the `index.md` body appears as prose above the listing) + +### Unlisted Behavior + +`unlisted: true` on a regular page hides it from nav and folder listings (URL still works). + +`unlisted: true` on an `index.md` or folder-note: the page still renders at `/folder/`, but no nav entry links to it. The folder itself remains in nav with its children. Use this for section overview pages that you want reachable via direct URL but not linked from the menu. + +To hide an entire folder, set `unlisted: true` in the folder's `_folder.md`. + See [Examples](/examples/) for a live example with custom content. diff --git a/src/rockgarden/content/__init__.py b/src/rockgarden/content/__init__.py index ebbd0b1..e075454 100644 --- a/src/rockgarden/content/__init__.py +++ b/src/rockgarden/content/__init__.py @@ -9,8 +9,8 @@ ) from rockgarden.content.format_loader import load_collection_data_files, load_data_file from rockgarden.content.link_index import LinkIndex, build_link_index -from rockgarden.content.loader import load_content -from rockgarden.content.models import Page +from rockgarden.content.loader import load_content, load_folder_metas +from rockgarden.content.models import FolderMeta, Page from rockgarden.content.models_loader import resolve_model, validate_entry from rockgarden.content.store import ContentStore from rockgarden.content.strip_title import strip_content_title @@ -18,6 +18,7 @@ __all__ = [ "Collection", "ContentStore", + "FolderMeta", "LinkIndex", "Page", "build_link_index", @@ -27,6 +28,7 @@ "load_collection_data_files", "load_content", "load_data_file", + "load_folder_metas", "partition_collections", "resolve_model", "strip_content_title", diff --git a/src/rockgarden/content/loader.py b/src/rockgarden/content/loader.py index 956a58f..1f7ba2a 100644 --- a/src/rockgarden/content/loader.py +++ b/src/rockgarden/content/loader.py @@ -8,9 +8,11 @@ import frontmatter from rockgarden.config import DatesConfig -from rockgarden.content.models import Page +from rockgarden.content.models import FolderMeta, Page from rockgarden.urls import generate_slug +FOLDER_META_FILENAME = "_folder.md" + logger = logging.getLogger(__name__) @@ -152,6 +154,9 @@ def load_content( if should_ignore(path, source, ignore_patterns): continue + if path.name == FOLDER_META_FILENAME: + continue + page = load_page(path, source, dates_config, url_style, ascii_urls) pages.append(page) @@ -178,3 +183,36 @@ def load_content( ) return pages + + +def load_folder_metas( + source: Path, + ignore_patterns: list[str], +) -> dict[str, FolderMeta]: + """Discover and load all `_folder.md` files from source directory. + + Args: + source: The source directory to scan. + ignore_patterns: List of glob patterns to ignore. + + Returns: + Dict mapping folder path (relative to source, with forward slashes, + empty string for the source root) to FolderMeta. + """ + metas: dict[str, FolderMeta] = {} + + for path in source.rglob(FOLDER_META_FILENAME): + if should_ignore(path, source, ignore_patterns): + continue + + post = frontmatter.load(path) + rel_parent = path.parent.relative_to(source) + folder_path = "" if rel_parent == Path(".") else rel_parent.as_posix() + + metas[folder_path] = FolderMeta( + source_path=path, + folder_path=folder_path, + frontmatter=dict(post.metadata), + ) + + return metas diff --git a/src/rockgarden/content/models.py b/src/rockgarden/content/models.py index ab8a80b..ce9f6bc 100644 --- a/src/rockgarden/content/models.py +++ b/src/rockgarden/content/models.py @@ -23,3 +23,42 @@ def title(self) -> str: if title := self.frontmatter.get("title"): return title return self.source_path.stem.replace("_", " ") + + +@dataclass +class FolderMeta: + """Folder-level metadata loaded from an optional `_folder.md` file. + + Unlike Page, FolderMeta does not produce a URL or appear in the content + store or link index. Its body (if any) is ignored. Only frontmatter fields + are consumed. + """ + + source_path: Path + folder_path: str + frontmatter: dict = field(default_factory=dict) + + @property + def nav_order(self) -> int | None: + return self.frontmatter.get("nav_order") + + @property + def label(self) -> str | None: + return self.frontmatter.get("label") + + @property + def sort(self) -> str | None: + return self.frontmatter.get("sort") + + @property + def sort_reverse(self) -> bool | None: + val = self.frontmatter.get("sort_reverse") + return bool(val) if val is not None else None + + @property + def unlisted(self) -> bool: + return bool(self.frontmatter.get("unlisted", False)) + + @property + def show_index(self) -> bool: + return bool(self.frontmatter.get("show_index", False)) diff --git a/src/rockgarden/nav/breadcrumbs.py b/src/rockgarden/nav/breadcrumbs.py index 4ac08ed..dde7bbc 100644 --- a/src/rockgarden/nav/breadcrumbs.py +++ b/src/rockgarden/nav/breadcrumbs.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from rockgarden.config import NavConfig -from rockgarden.content import Page +from rockgarden.content import FolderMeta, Page from rockgarden.nav.labels import resolve_label from rockgarden.urls import get_folder_url, get_url @@ -24,20 +24,25 @@ def build_breadcrumbs( config: NavConfig | None = None, clean_urls: bool = True, base_path: str = "", + folder_metas: dict[str, FolderMeta] | None = None, ) -> list[Breadcrumb]: """Build breadcrumb trail for a page. Args: page: The current page - pages: All pages (for looking up folder index titles) + pages: All pages (used to derive folder-index title fallback) config: Navigation config (for label overrides) clean_urls: If True, use /path/ instead of /path/index.html + folder_metas: Optional dict of folder-path → FolderMeta for label + resolution. Returns: List of Breadcrumb objects from root to current page """ if config is None: config = NavConfig() + if folder_metas is None: + folder_metas = {} folder_pages: dict[str, Page] = {} for p in pages: @@ -58,7 +63,7 @@ def build_breadcrumbs( breadcrumbs: list[Breadcrumb] = [] - root_label = resolve_label("", "Home", config.labels, folder_pages) + root_label = resolve_label("", "Home", config.labels, folder_metas, folder_pages) root_path = get_folder_url("", clean_urls, base_path) breadcrumbs.append(Breadcrumb(label=root_label, path=root_path)) @@ -73,7 +78,9 @@ def build_breadcrumbs( folder_path = "/".join(current_path_parts) original_name = original_folder_names.get(folder_path, part) - label = resolve_label(folder_path, original_name, config.labels, folder_pages) + label = resolve_label( + folder_path, original_name, config.labels, folder_metas, folder_pages + ) folder_url = get_folder_url(folder_path, clean_urls, base_path) breadcrumbs.append(Breadcrumb(label=label, path=folder_url)) diff --git a/src/rockgarden/nav/folder_index.py b/src/rockgarden/nav/folder_index.py index 9071ab6..7386b76 100644 --- a/src/rockgarden/nav/folder_index.py +++ b/src/rockgarden/nav/folder_index.py @@ -6,7 +6,7 @@ from datetime import datetime from rockgarden.config import NavConfig -from rockgarden.content import Page +from rockgarden.content import FolderMeta, Page from rockgarden.nav.labels import resolve_label from rockgarden.nav.sort import resolve_sort from rockgarden.urls import get_folder_url, get_url @@ -59,6 +59,7 @@ def generate_folder_indexes( clean_urls: bool = True, base_path: str = "", site_title: str = "", + folder_metas: dict[str, FolderMeta] | None = None, ) -> list[FolderIndex]: """Generate folder index data for all folders. @@ -67,12 +68,16 @@ def generate_folder_indexes( config: Navigation config for hide patterns and labels clean_urls: If True, use /path/ instead of /path/index.html site_title: Site title used as the root index title fallback + folder_metas: Optional dict of folder-path → FolderMeta (from + `_folder.md` files). Controls folder unlisting and label. Returns: List of FolderIndex objects for folders that need generated indexes """ if config is None: config = NavConfig() + if folder_metas is None: + folder_metas = {} folders = find_folders(pages) @@ -88,12 +93,12 @@ def generate_folder_indexes( for folder_path in sorted(folders): if _should_hide(folder_path, config.hide): continue - index_page = existing_indexes.get(folder_path) - if index_page and index_page.frontmatter.get("unlisted", False): + folder_meta = folder_metas.get(folder_path) + if folder_meta and folder_meta.unlisted: continue children = _get_folder_children( - folder_path, pages, config, clean_urls, base_path + folder_path, pages, config, clean_urls, base_path, folder_metas ) if folder_path in existing_indexes: @@ -103,7 +108,9 @@ def generate_folder_indexes( frontmatter = index_page.frontmatter else: folder_name = folder_path.split("/")[-1] - title = resolve_label(folder_path, folder_name, config.labels) + title = resolve_label( + folder_path, folder_name, config.labels, folder_metas, existing_indexes + ) if not title and not folder_path: title = site_title or "Home" custom_content = None @@ -180,8 +187,12 @@ def _get_folder_children( config: NavConfig, clean_urls: bool = True, base_path: str = "", + folder_metas: dict[str, FolderMeta] | None = None, ) -> list[FolderChild]: """Get direct children of a folder.""" + if folder_metas is None: + folder_metas = {} + children: list[FolderChild] = [] seen_subfolders: set[str] = set() @@ -234,20 +245,22 @@ def _get_folder_children( if subfolder_path not in seen_subfolders: if _should_hide(subfolder_path, config.hide): continue - subfolder_index = folder_index_pages.get(subfolder_path) - if subfolder_index and subfolder_index.frontmatter.get( - "unlisted", False - ): + subfolder_meta = folder_metas.get(subfolder_path) + if subfolder_meta and subfolder_meta.unlisted: continue seen_subfolders.add(subfolder_path) - label = resolve_label(subfolder_path, subfolder, config.labels) + label = resolve_label( + subfolder_path, + subfolder, + config.labels, + folder_metas, + folder_index_pages, + ) nav_order = None - if subfolder_path in folder_index_pages: - nav_order = folder_index_pages[subfolder_path].frontmatter.get( - "nav_order" - ) + if subfolder_path in folder_metas: + nav_order = folder_metas[subfolder_path].nav_order children.append( FolderChild( @@ -260,9 +273,7 @@ def _get_folder_children( ) folder_fm = ( - folder_index_pages[folder_path].frontmatter - if folder_path in folder_index_pages - else None + folder_metas[folder_path].frontmatter if folder_path in folder_metas else None ) resolved = resolve_sort(folder_path, config, folder_fm) return _sort_folder_children(children, resolved.sort, resolved.reverse) diff --git a/src/rockgarden/nav/labels.py b/src/rockgarden/nav/labels.py index 84bd11d..ae7b2bb 100644 --- a/src/rockgarden/nav/labels.py +++ b/src/rockgarden/nav/labels.py @@ -1,27 +1,33 @@ """Label resolution for navigation items.""" -from rockgarden.content import Page +from rockgarden.content import FolderMeta, Page def resolve_label( path: str, name: str, labels: dict[str, str], + folder_metas: dict[str, FolderMeta] | None = None, folder_pages: dict[str, Page] | None = None, ) -> str: """Resolve display label for a nav item. Resolution order: 1. Config override (labels dict) - 2. Folder's index page title (uses Page.title which checks frontmatter then - filename) - 3. Name with underscores replaced by spaces (fallback when no index page) + 2. `label` field in the folder's `_folder.md` metadata + 3. Folder index page title (from index.md / folder-note frontmatter) + 4. Name with underscores replaced by spaces (fallback) """ normalized_path = f"/{path.strip('/')}" if path else "/" if normalized_path in labels: return labels[normalized_path] + if folder_metas and path in folder_metas: + meta_label = folder_metas[path].label + if meta_label: + return meta_label + if folder_pages and path in folder_pages: return folder_pages[path].title diff --git a/src/rockgarden/nav/sort.py b/src/rockgarden/nav/sort.py index 8da1b00..ce226ad 100644 --- a/src/rockgarden/nav/sort.py +++ b/src/rockgarden/nav/sort.py @@ -20,7 +20,11 @@ def resolve_sort( ) -> ResolvedSort: """Resolve effective sort config for a folder. - Priority: frontmatter > config overrides > global defaults. + Priority: `_folder.md` metadata > config overrides > global defaults. + + `folder_frontmatter` is a flat dict of folder-level metadata (typically + the frontmatter from a `_folder.md` file). It may contain `sort` and/or + `sort_reverse` to override config for this folder. """ sort = nav_config.sort reverse = nav_config.reverse diff --git a/src/rockgarden/nav/tree.py b/src/rockgarden/nav/tree.py index 2873578..72bc3e2 100644 --- a/src/rockgarden/nav/tree.py +++ b/src/rockgarden/nav/tree.py @@ -6,7 +6,7 @@ from fnmatch import fnmatch from rockgarden.config import NavConfig, NavLinkConfig -from rockgarden.content import Page +from rockgarden.content import FolderMeta, Page from rockgarden.nav.labels import resolve_label from rockgarden.nav.sort import resolve_sort from rockgarden.urls import get_folder_url, get_url @@ -87,6 +87,7 @@ def build_nav_tree( config: NavConfig | None = None, clean_urls: bool = True, base_path: str = "", + folder_metas: dict[str, FolderMeta] | None = None, ) -> NavNode: """Build navigation tree from a list of pages. @@ -94,12 +95,17 @@ def build_nav_tree( pages: List of Page objects from the content store config: Navigation configuration (hide patterns, labels, etc.) clean_urls: If True, use /path/ instead of /path/index.html + folder_metas: Optional dict of folder-path → FolderMeta (from + `_folder.md` files). Used for folder nav_order, label, sort, + and unlisted. Returns: Root NavNode containing the full navigation tree """ if config is None: config = NavConfig() + if folder_metas is None: + folder_metas = {} folder_pages: dict[str, Page] = {} for page in pages: @@ -138,9 +144,9 @@ def build_nav_tree( current_path_parts.append(part) folder_path = "/".join(current_path_parts) - folder_page = folder_pages.get(folder_path) + folder_meta = folder_metas.get(folder_path) if _should_hide(folder_path, config.hide) or ( - folder_page and folder_page.frontmatter.get("unlisted", False) + folder_meta and folder_meta.unlisted ): break @@ -175,10 +181,12 @@ def dict_to_nodes(d: dict, parent_path: str = "") -> list[NavNode]: if is_folder: original_name = data.get("_original_name", name) - label = resolve_label(path, original_name, config.labels, folder_pages) + label = resolve_label( + path, original_name, config.labels, folder_metas, folder_pages + ) url_path = get_folder_url(path, clean_urls, base_path) - if path in folder_pages: - nav_order = folder_pages[path].frontmatter.get("nav_order") + if path in folder_metas: + nav_order = folder_metas[path].nav_order else: page = data.get("_page") label = page.title if page else name @@ -187,7 +195,7 @@ def dict_to_nodes(d: dict, parent_path: str = "") -> list[NavNode]: nav_order = page.frontmatter.get("nav_order") children = dict_to_nodes(data.get("_children", {}), path) - folder_fm = folder_pages[path].frontmatter if path in folder_pages else None + folder_fm = folder_metas[path].frontmatter if path in folder_metas else None resolved = resolve_sort(path, config, folder_fm) children = _sort_nav_nodes(children, resolved.sort, resolved.reverse) @@ -195,7 +203,8 @@ def dict_to_nodes(d: dict, parent_path: str = "") -> list[NavNode]: if is_folder: if path in folder_pages: index_page = folder_pages[path] - index_path = get_url(index_page.slug, clean_urls, base_path) + if not index_page.frontmatter.get("unlisted", False): + index_path = get_url(index_page.slug, clean_urls, base_path) elif config.link_auto_index: index_path = url_path @@ -214,7 +223,7 @@ def dict_to_nodes(d: dict, parent_path: str = "") -> list[NavNode]: root_resolved = resolve_sort("", config) return _sort_nav_nodes(nodes, root_resolved.sort, root_resolved.reverse) - root_label = resolve_label("", "Home", config.labels, folder_pages) + root_label = resolve_label("", "Home", config.labels, folder_metas, folder_pages) root_children = dict_to_nodes(tree) return NavNode( diff --git a/src/rockgarden/output/builder.py b/src/rockgarden/output/builder.py index 355bbad..f9ee144 100644 --- a/src/rockgarden/output/builder.py +++ b/src/rockgarden/output/builder.py @@ -25,6 +25,7 @@ get_collection_skip_slugs, load_collection_data_files, load_content, + load_folder_metas, partition_collections, resolve_model, strip_content_title, @@ -58,6 +59,7 @@ BuildManifest, PageManifestEntry, compute_config_hash, + compute_folder_meta_hash, compute_macro_hash, compute_template_hash, hash_file, @@ -430,6 +432,7 @@ def build_site( config.site.url_style, config.site.ascii_urls, ) + folder_metas = load_folder_metas(source, config.build.ignore_patterns) clean_urls = config.site.clean_urls base_path = config.site.base_path or get_base_path(config.site.base_url) @@ -451,6 +454,9 @@ def build_site( cur_config_hash = compute_config_hash(config_path) cur_template_hash = compute_template_hash(site_root, config.theme.name) cur_macro_hash = compute_macro_hash(site_root) + cur_folder_meta_hash = compute_folder_meta_hash( + source, config.build.ignore_patterns + ) output_dir_str = str(output.resolve()) cur_cdn_flags = f"math={math_cdn},mermaid={mermaid_cdn}" @@ -462,6 +468,7 @@ def build_site( output_dir_str, len(pages), cur_cdn_flags, + cur_folder_meta_hash, ): use_incremental = True else: @@ -472,6 +479,7 @@ def build_site( output_dir=output_dir_str, page_count=len(pages), cdn_flags=cur_cdn_flags, + folder_meta_hash=cur_folder_meta_hash, ) collections = partition_collections(pages, config.collections, source) @@ -516,7 +524,9 @@ def build_site( env_vars=hook_env, ) - nav_tree = build_nav_tree(pages, config.nav, clean_urls, base_path) + nav_tree = build_nav_tree( + pages, config.nav, clean_urls, base_path, folder_metas=folder_metas + ) env = create_engine(config, site_root=site_root, base_path=base_path) env.globals["collections"] = { @@ -558,13 +568,17 @@ def build_site( "feed_path": config.feed.path, } - show_index_map = {} + show_index_map = { + folder_path: meta.show_index for folder_path, meta in folder_metas.items() + } + # Folders whose index.md should render as the page at /folder/ (unless + # _folder.md sets show_index=true, in which case an auto-generated listing + # replaces the index.md page output). + folders_with_index_page: set[str] = set() for p in pages: parts = p.slug.split("/") if parts[-1] == "index": - folder_path = "/".join(parts[:-1]) - show_index = p.frontmatter.get("show_index", False) - show_index_map[folder_path] = show_index + folders_with_index_page.add("/".join(parts[:-1])) # Pre-compute collection nav nodes so they're visible to all templates # (including collection page templates themselves). from rockgarden.nav.tree import NavNode @@ -692,7 +706,9 @@ def build_site( page.html, max_level=config.toc.max_depth ) - breadcrumbs = build_breadcrumbs(page, pages, config.nav, clean_urls, base_path) + breadcrumbs = build_breadcrumbs( + page, pages, config.nav, clean_urls, base_path, folder_metas=folder_metas + ) # Get backlinks if enabled backlinks_tree = None @@ -705,7 +721,11 @@ def build_site( ] if backlink_pages: backlinks_tree = build_nav_tree( - backlink_pages, config.nav, clean_urls, base_path + backlink_pages, + config.nav, + clean_urls, + base_path, + folder_metas=folder_metas, ) layout_template = resolve_layout(page.frontmatter, config.theme.default_layout) @@ -735,14 +755,23 @@ def build_site( count += 1 folder_indexes = generate_folder_indexes( - pages, config.nav, clean_urls, base_path, config.site.title + pages, + config.nav, + clean_urls, + base_path, + config.site.title, + folder_metas=folder_metas, ) rendered_folder_indexes: list = [] folder_template = env.get_template("folder_index.html") for folder in folder_indexes: folder_path = folder.slug.rsplit("/", 1)[0] if "/" in folder.slug else "" - if folder_path in show_index_map and not show_index_map[folder_path]: + # If a real index.md exists and _folder.md doesn't request the auto + # listing, the index.md itself provides the page at /folder/. + if folder_path in folders_with_index_page and not show_index_map.get( + folder_path, False + ): continue if folder.custom_content: @@ -779,7 +808,7 @@ def build_site( folder.custom_content = process_callouts(render_markdown(processed)) breadcrumbs = _build_folder_breadcrumbs( - folder, pages, config.nav, clean_urls, base_path + folder, pages, config.nav, clean_urls, base_path, folder_metas ) folder_layout = resolve_layout(folder.frontmatter, config.theme.default_layout) @@ -934,11 +963,21 @@ def build_site( ) -def _build_folder_breadcrumbs(folder, pages, nav_config, clean_urls=True, base_path=""): +def _build_folder_breadcrumbs( + folder, + pages, + nav_config, + clean_urls=True, + base_path="", + folder_metas=None, +): """Build breadcrumbs for a folder index page.""" from rockgarden.nav import Breadcrumb, resolve_label - folder_pages: dict[str, any] = {} + if folder_metas is None: + folder_metas = {} + + folder_pages: dict = {} for p in pages: parts = p.slug.split("/") if parts[-1] == "index": @@ -947,7 +986,9 @@ def _build_folder_breadcrumbs(folder, pages, nav_config, clean_urls=True, base_p breadcrumbs = [] - root_label = resolve_label("", "Home", nav_config.labels, folder_pages) + root_label = resolve_label( + "", "Home", nav_config.labels, folder_metas, folder_pages + ) root_path = get_folder_url("", clean_urls, base_path) breadcrumbs.append(Breadcrumb(label=root_label, path=root_path)) @@ -962,7 +1003,7 @@ def _build_folder_breadcrumbs(folder, pages, nav_config, clean_urls=True, base_p current_parts.append(part) path = "/".join(current_parts) - label = resolve_label(path, part, nav_config.labels, folder_pages) + label = resolve_label(path, part, nav_config.labels, folder_metas, folder_pages) folder_url = get_folder_url(path, clean_urls, base_path) breadcrumbs.append(Breadcrumb(label=label, path=folder_url)) diff --git a/src/rockgarden/output/manifest.py b/src/rockgarden/output/manifest.py index 045aa61..20c5799 100644 --- a/src/rockgarden/output/manifest.py +++ b/src/rockgarden/output/manifest.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from pathlib import Path -MANIFEST_VERSION = 1 +MANIFEST_VERSION = 2 @dataclass @@ -25,6 +25,7 @@ class BuildManifest: output_dir: str page_count: int cdn_flags: str = "" + folder_meta_hash: str = "" pages: dict[str, PageManifestEntry] = field(default_factory=dict) @classmethod @@ -47,6 +48,7 @@ def load(cls, path: Path) -> BuildManifest | None: output_dir=data["output_dir"], page_count=data["page_count"], cdn_flags=data.get("cdn_flags", ""), + folder_meta_hash=data.get("folder_meta_hash", ""), pages=pages, ) except (json.JSONDecodeError, KeyError, TypeError): @@ -63,6 +65,7 @@ def save(self, path: Path) -> None: "output_dir": self.output_dir, "page_count": self.page_count, "cdn_flags": self.cdn_flags, + "folder_meta_hash": self.folder_meta_hash, "pages": { slug: {"content_hash": e.content_hash, "output_path": e.output_path} for slug, e in self.pages.items() @@ -88,6 +91,7 @@ def needs_full_rebuild( output_dir: str, page_count: int, cdn_flags: str = "", + folder_meta_hash: str = "", ) -> bool: """Check if a full rebuild is needed due to global changes.""" if self.config_hash != config_hash: @@ -102,6 +106,8 @@ def needs_full_rebuild( return True if self.cdn_flags != cdn_flags: return True + if self.folder_meta_hash != folder_meta_hash: + return True if not Path(output_dir).exists(): return True return False @@ -153,3 +159,30 @@ def compute_template_hash(site_root: Path, theme_name: str) -> str: def compute_macro_hash(site_root: Path) -> str: """Hash all _macros/ files.""" return hash_directory(site_root / "_macros") + + +def compute_folder_meta_hash(source: Path, ignore_patterns: list[str]) -> str: + """Hash all `_folder.md` files under the source directory. + + Any change to folder metadata must invalidate all pages on incremental + builds because folder metadata (nav order, labels, unlisting) is + rendered into every page's nav. + + Respects `ignore_patterns` so that `_folder.md` files inside ignored + directories (which `load_folder_metas` also skips) don't cause spurious + full rebuilds. + """ + from rockgarden.content.loader import should_ignore + + h = hashlib.sha256() + if not source.exists(): + return h.hexdigest() + for file_path in sorted(source.rglob("_folder.md")): + if not file_path.is_file(): + continue + if should_ignore(file_path, source, ignore_patterns): + continue + rel = str(file_path.relative_to(source)) + h.update(rel.encode()) + h.update(file_path.read_bytes()) + return h.hexdigest() diff --git a/tests/test_folder_index.py b/tests/test_folder_index.py index d7a6c1d..6831947 100644 --- a/tests/test_folder_index.py +++ b/tests/test_folder_index.py @@ -3,7 +3,7 @@ from pathlib import Path from rockgarden.config import FolderSortOverride, NavConfig -from rockgarden.content import Page +from rockgarden.content import FolderMeta, Page from rockgarden.nav.folder_index import ( find_folders, generate_folder_indexes, @@ -24,6 +24,14 @@ def make_page( ) +def make_meta(folder_path: str, **fields) -> FolderMeta: + return FolderMeta( + source_path=Path(f"/vault/{folder_path}/_folder.md"), + folder_path=folder_path, + frontmatter=dict(fields), + ) + + class TestFindFolders: def test_empty_pages(self): assert find_folders([]) == set() @@ -209,24 +217,21 @@ def test_config_override(self): docs_titles = [c.title for c in docs.children] assert docs_titles == ["One", "Two"] - def test_frontmatter_override_wins(self): + def test_folder_meta_sort_override_wins(self): pages = [ - Page( - source_path=Path("/vault/blog/index.md"), - slug="blog/index", - frontmatter={"sort": "alphabetical", "sort_reverse": True}, - content="", - ), make_page("blog/alpha", "Alpha"), make_page("blog/beta", "Beta"), make_page("blog/gamma", "Gamma"), ] + metas = { + "blog": make_meta("blog", sort="alphabetical", sort_reverse=True), + } config = NavConfig( sort="alphabetical", reverse=False, overrides={"blog": FolderSortOverride(reverse=False)}, ) - indexes = generate_folder_indexes(pages, config) + indexes = generate_folder_indexes(pages, config, folder_metas=metas) 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"] @@ -285,36 +290,39 @@ def test_unlisted_page_hidden_from_folder_children(self): assert "Visible" in child_titles assert "Secret" not in child_titles - def test_unlisted_folder_hidden_from_parent_index(self): - """Folder with unlisted index page not shown in parent's children.""" + def test_unlisted_folder_meta_hides_from_parent_index(self): + """Folder with `_folder.md` unlisted=true not shown in parent's children.""" pages = [ make_page("docs/public", "Public"), - Page( - source_path=Path("/vault/docs/secret/index.md"), - slug="docs/secret/index", - frontmatter={"title": "Secret Folder", "unlisted": True}, - content="", - ), + make_page("docs/secret/index", "Secret Folder"), make_page("docs/secret/inner", "Inner Page"), ] - indexes = generate_folder_indexes(pages) + metas = {"docs/secret": make_meta("docs/secret", unlisted=True)} + indexes = generate_folder_indexes(pages, folder_metas=metas) docs = next(fi for fi in indexes if fi.slug == "docs/index") child_titles = [c.title for c in docs.children] assert "Public" in child_titles assert "Secret Folder" not in child_titles assert "secret" not in [c.title.lower() for c in docs.children] - def test_unlisted_folder_not_in_generated_indexes(self): - """Folder with unlisted index should not get a generated folder index.""" + def test_unlisted_folder_meta_not_in_generated_indexes(self): + """Folder with `_folder.md` unlisted=true should not get a generated index.""" pages = [ - Page( - source_path=Path("/vault/secret/index.md"), - slug="secret/index", - frontmatter={"title": "Secret", "unlisted": True}, - content="", - ), + make_page("secret/index", "Secret"), make_page("secret/inner", "Inner"), ] - indexes = generate_folder_indexes(pages) + metas = {"secret": make_meta("secret", unlisted=True)} + indexes = generate_folder_indexes(pages, folder_metas=metas) index_slugs = [fi.slug for fi in indexes] assert "secret/index" not in index_slugs + + +class TestFolderMetaWithoutDescendants: + """`_folder.md` alone should not materialize an empty folder index.""" + + def test_folder_meta_with_no_pages_not_emitted(self): + pages = [make_page("about", "About")] + metas = {"empty": make_meta("empty", label="Empty")} + indexes = generate_folder_indexes(pages, folder_metas=metas) + index_slugs = [fi.slug for fi in indexes] + assert "empty/index" not in index_slugs diff --git a/tests/test_incremental.py b/tests/test_incremental.py index de428d6..c18e8e4 100644 --- a/tests/test_incremental.py +++ b/tests/test_incremental.py @@ -64,6 +64,24 @@ def test_edited_page_rebuilds(self, tmp_path): html = (output / "about" / "index.html").read_text() assert "Updated content" in html + def test_folder_meta_change_triggers_full_rebuild(self, tmp_path): + source = tmp_path / "content" + source.mkdir() + (source / "blog").mkdir() + _write_page(source, "blog/post-one") + _write_page(source, "blog/post-two") + output = tmp_path / "output" + + # Initial build with an unlisted blog folder. + (source / "blog" / "_folder.md").write_text("---\nunlisted: true\n---\n") + _build(source, output) + + # Flip the folder to visible; all pages must re-render because folder + # metadata affects every page's nav. + (source / "blog" / "_folder.md").write_text("---\nnav_order: 1\n---\n") + result = _build(source, output) + assert result.skipped_count == 0 + def test_config_change_triggers_full_rebuild(self, tmp_path): source = tmp_path / "content" source.mkdir() diff --git a/tests/test_loader.py b/tests/test_loader.py index 9fdd721..12bb490 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -4,7 +4,7 @@ import pytest -from rockgarden.content.loader import load_content, load_page +from rockgarden.content.loader import load_content, load_folder_metas, load_page @pytest.fixture @@ -94,3 +94,55 @@ def test_conflict_emits_warning(self, source_dir, caplog): load_content(source_dir, []) assert "index.md" in caplog.text assert "folder page" in caplog.text + + +class TestFolderMetaLoading: + def test_folder_md_not_loaded_as_page(self, source_dir): + """`_folder.md` files should not appear in loaded pages.""" + _write_page(source_dir, "blog/_folder.md", "---\nnav_order: 1\n---\n") + _write_page(source_dir, "blog/post.md", "# Post") + + pages = load_content(source_dir, []) + slugs = [p.slug for p in pages] + assert "blog/_folder" not in slugs + assert "blog/post" in slugs + + def test_folder_md_metadata_loaded(self, source_dir): + """`_folder.md` frontmatter is loaded into FolderMeta.""" + _write_page( + source_dir, + "blog/_folder.md", + "---\nnav_order: 2\nlabel: Blog Posts\nsort: alphabetical\n---\n", + ) + _write_page(source_dir, "blog/post.md", "# Post") + + metas = load_folder_metas(source_dir, []) + assert "blog" in metas + assert metas["blog"].nav_order == 2 + assert metas["blog"].label == "Blog Posts" + assert metas["blog"].sort == "alphabetical" + + def test_root_folder_meta(self, source_dir): + """A `_folder.md` at the source root is keyed by empty string.""" + _write_page(source_dir, "_folder.md", "---\nlabel: Site\n---\n") + + metas = load_folder_metas(source_dir, []) + assert "" in metas + assert metas[""].label == "Site" + + def test_nested_folder_meta(self, source_dir): + """Folder metas are keyed by forward-slash-joined folder path.""" + _write_page(source_dir, "a/b/c/_folder.md", "---\nnav_order: 3\n---\n") + + metas = load_folder_metas(source_dir, []) + assert "a/b/c" in metas + assert metas["a/b/c"].nav_order == 3 + + def test_empty_folder_meta(self, source_dir): + """`_folder.md` with no frontmatter loads as empty FolderMeta.""" + _write_page(source_dir, "blog/_folder.md", "just a body, no frontmatter") + + metas = load_folder_metas(source_dir, []) + assert "blog" in metas + assert metas["blog"].nav_order is None + assert metas["blog"].unlisted is False diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 387f1e0..ac8d0ae 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -5,6 +5,7 @@ from rockgarden.output.manifest import ( BuildManifest, PageManifestEntry, + compute_folder_meta_hash, hash_directory, hash_file, ) @@ -160,3 +161,31 @@ def test_no_full_rebuild_when_unchanged(self, tmp_path): page_count=3, ) assert not m.needs_full_rebuild("c", "t", "m", str(out), 3) + + +class TestComputeFolderMetaHash: + def test_includes_folder_md_content(self, tmp_path): + (tmp_path / "blog").mkdir() + (tmp_path / "blog" / "_folder.md").write_text("---\nnav_order: 1\n---\n") + h1 = compute_folder_meta_hash(tmp_path, []) + + (tmp_path / "blog" / "_folder.md").write_text("---\nnav_order: 2\n---\n") + h2 = compute_folder_meta_hash(tmp_path, []) + assert h1 != h2 + + def test_skips_ignored_directories(self, tmp_path): + (tmp_path / "blog").mkdir() + (tmp_path / "blog" / "_folder.md").write_text("---\nnav_order: 1\n---\n") + (tmp_path / ".obsidian").mkdir() + (tmp_path / ".obsidian" / "_folder.md").write_text("---\nnav_order: 1\n---\n") + + h1 = compute_folder_meta_hash(tmp_path, [".obsidian"]) + + # Changing an ignored _folder.md must not affect the hash. + (tmp_path / ".obsidian" / "_folder.md").write_text("---\nnav_order: 99\n---\n") + h2 = compute_folder_meta_hash(tmp_path, [".obsidian"]) + assert h1 == h2 + + def test_empty_source(self, tmp_path): + h = compute_folder_meta_hash(tmp_path, []) + assert isinstance(h, str) and len(h) == 64 diff --git a/tests/test_nav_tree.py b/tests/test_nav_tree.py index e72003d..3a3bd23 100644 --- a/tests/test_nav_tree.py +++ b/tests/test_nav_tree.py @@ -3,7 +3,7 @@ from pathlib import Path from rockgarden.config import FolderSortOverride, NavConfig, NavLinkConfig -from rockgarden.content import Page +from rockgarden.content import FolderMeta, Page from rockgarden.nav import build_nav_tree from rockgarden.nav.tree import convert_nav_links, inject_nav_links @@ -25,6 +25,15 @@ def make_page( ) +def make_meta(folder_path: str, **fields) -> FolderMeta: + """Helper to construct a FolderMeta with the given frontmatter fields.""" + return FolderMeta( + source_path=Path(f"/vault/{folder_path}/_folder.md"), + folder_path=folder_path, + frontmatter=dict(fields), + ) + + class TestBuildNavTree: def test_empty_pages(self): """Empty page list produces root-only tree.""" @@ -143,7 +152,7 @@ def test_label_override(self): assert characters.label == "Cast" def test_label_from_index_frontmatter(self): - """Folder label comes from index.md frontmatter title.""" + """Folder label falls back to index.md title.""" pages = [ make_page("characters/index", "The Characters"), make_page("characters/alice", "Alice"), @@ -153,6 +162,18 @@ def test_label_from_index_frontmatter(self): characters = tree.children[0] assert characters.label == "The Characters" + def test_label_from_folder_meta_overrides_index_title(self): + """`_folder.md` label overrides the index.md title.""" + pages = [ + make_page("characters/index", "The Characters"), + make_page("characters/alice", "Alice"), + ] + metas = {"characters": make_meta("characters", label="Cast")} + tree = build_nav_tree(pages, folder_metas=metas) + + characters = tree.children[0] + assert characters.label == "Cast" + def test_config_label_overrides_frontmatter(self): """Config label takes precedence over frontmatter.""" pages = [ @@ -223,15 +244,16 @@ def test_nav_order_negative(self): assert tree.children[1].label == "Getting Started" assert tree.children[2].label == "About" - def test_nav_order_folder_from_index(self): - """Folder nav_order comes from its index.md frontmatter.""" + def test_nav_order_folder_from_meta(self): + """Folder nav_order comes from `_folder.md`.""" pages = [ - make_page("guides/index", "Guides", nav_order=1), + make_page("guides/index", "Guides"), make_page("guides/quick-start", "Quick Start"), make_page("reference/index", "Reference"), make_page("reference/api", "API"), ] - tree = build_nav_tree(pages) + metas = {"guides": make_meta("guides", label="Guides", nav_order=1)} + tree = build_nav_tree(pages, folder_metas=metas) assert tree.children[0].label == "Guides" assert tree.children[0].nav_order == 1 @@ -443,25 +465,19 @@ def test_config_override_applies_to_folder(self): labels = [c.label for c in blog_node.children] assert labels == ["gamma", "beta", "alpha"] - def test_frontmatter_override_wins(self): - """Frontmatter sort/sort_reverse should override config.""" + def test_folder_meta_override_wins(self): + """`_folder.md` sort/sort_reverse should override config.""" pages = [ - Page( - source_path=Path("/vault/blog/index.md"), - slug="blog/index", - frontmatter={ - "title": "Blog", - "sort": "alphabetical", - "sort_reverse": True, - }, - content="", - ), + make_page("blog/index", "Blog"), make_page("blog/alpha"), make_page("blog/beta"), make_page("blog/gamma"), ] + metas = { + "blog": make_meta("blog", sort="alphabetical", sort_reverse=True), + } config = NavConfig(sort="alphabetical", reverse=False) - tree = build_nav_tree(pages, config) + tree = build_nav_tree(pages, config, folder_metas=metas) blog_node = [c for c in tree.children if c.name == "blog"][0] labels = [c.label for c in blog_node.children] assert labels == ["gamma", "beta", "alpha"] @@ -573,25 +589,54 @@ def test_unlisted_false_still_shown(self): labels = [c.label for c in tree.children] assert "Page" in labels - def test_unlisted_folder_hidden_from_nav(self): - """Folder with unlisted index page should not appear in nav.""" + def test_unlisted_folder_meta_hides_folder(self): + """Folder with unlisted=true in `_folder.md` should not appear in nav.""" + pages = [ + make_page("secret/index", "Secret"), + make_page("secret/details", "Details"), + make_page("public", "Public"), + ] + metas = {"secret": make_meta("secret", unlisted=True)} + tree = build_nav_tree(pages, folder_metas=metas) + labels = [c.label for c in tree.children] + assert "Public" in labels + assert "secret" not in [c.label.lower() for c in tree.children] + assert "Details" not in labels + + def test_unlisted_index_page_renders_but_folder_visible(self): + """Unlisted on index.md: folder still visible, but no nav link to it.""" pages = [ Page( - source_path=Path("/vault/secret/index.md"), - slug="secret/index", - frontmatter={"title": "Secret", "unlisted": True}, + source_path=Path("/vault/about/index.md"), + slug="about/index", + frontmatter={"title": "About", "unlisted": True}, content="", ), + make_page("about/team", "Team"), + ] + tree = build_nav_tree(pages) + about = [c for c in tree.children if c.name == "about"] + assert len(about) == 1 + # Folder present with children, but index_path is None because the + # index page itself is unlisted. + assert about[0].is_folder + assert about[0].index_path is None + assert "Team" in [c.label for c in about[0].children] + + def test_unlisted_folder_note_equivalent_to_unlisted_index(self): + """Folder-note (attend/attend.md) with unlisted behaves like index.md.""" + pages = [ Page( - source_path=Path("/vault/secret/details.md"), - slug="secret/details", - frontmatter={"title": "Details"}, + source_path=Path("/vault/attend/attend.md"), + slug="attend/index", + frontmatter={"title": "Attend", "unlisted": True}, content="", ), - make_page("public", "Public"), + make_page("attend/venue", "Venue"), ] tree = build_nav_tree(pages) - labels = [c.label for c in tree.children] - assert "Public" in labels - assert "secret" not in [c.label.lower() for c in tree.children] - assert "Details" not in labels + attend = [c for c in tree.children if c.name == "attend"] + assert len(attend) == 1 + assert attend[0].is_folder + assert attend[0].index_path is None + assert "Venue" in [c.label for c in attend[0].children]