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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 38 additions & 5 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`:**

Expand All @@ -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
---
Expand All @@ -107,10 +124,27 @@ sort_reverse: true
---
```

Priority: frontmatter > `[nav.overrides.<path>]` > global `[nav]` defaults.
Priority: `_folder.md` > `[nav.overrides.<path>]` > 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.
Expand Down Expand Up @@ -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

Expand Down
38 changes: 28 additions & 10 deletions site/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,39 @@ title: Page Title # Used in nav and <title>
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

Expand All @@ -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

Expand Down
37 changes: 29 additions & 8 deletions site/Navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
---
Expand All @@ -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.
6 changes: 4 additions & 2 deletions src/rockgarden/content/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@
)
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

__all__ = [
"Collection",
"ContentStore",
"FolderMeta",
"LinkIndex",
"Page",
"build_link_index",
Expand All @@ -27,6 +28,7 @@
"load_collection_data_files",
"load_content",
"load_data_file",
"load_folder_metas",
"partition_collections",
"resolve_model",
"strip_content_title",
Expand Down
40 changes: 39 additions & 1 deletion src/rockgarden/content/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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)

Expand All @@ -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
39 changes: 39 additions & 0 deletions src/rockgarden/content/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
15 changes: 11 additions & 4 deletions src/rockgarden/nav/breadcrumbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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))

Expand All @@ -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))

Expand Down
Loading
Loading