Skip to content

Commit 0f92a8b

Browse files
authored
fix: add separate folder metadata support (#108)
separates folder metata from the index/folder info pages note: while this is a breaking change, there are no backward compatibility considerations yet since the project is only in light use.
1 parent c50e4aa commit 0f92a8b

19 files changed

Lines changed: 551 additions & 137 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ rockgarden serve # preview locally
4040

4141
**Navigation & discovery:**
4242

43-
- Auto-generated sidebar, breadcrumbs, folder index pages
43+
- Auto-generated sidebar, breadcrumbs, folder index pages (with optional `_folder.md` for per-folder nav metadata)
4444
- Per-page table of contents
4545
- Backlinks
4646
- Client-side full-text search

docs/Configuration.md

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ All styles strip the `.md` extension and preserve directory structure. Per-page
4444
url_style = "preserve-case"
4545
```
4646

47+
### Folder URLs
48+
49+
A folder renders at `/folder/` when it contains either:
50+
51+
- `index.md` — the canonical form
52+
- 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`.
53+
54+
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.
55+
56+
To place a page literally at `/folder/folder/` (bypassing the folder-note rewrite), set an explicit `slug` in the page's frontmatter:
57+
58+
```yaml
59+
---
60+
slug: attend/attend
61+
---
62+
```
63+
4764
## `[build]`
4865

4966
| Field | Type | Default | Description |
@@ -88,7 +105,7 @@ Theme-specific display and rendering options. These are supported by the default
88105

89106
### Per-folder sort overrides
90107

91-
Override `sort` and `reverse` for specific folders via config or frontmatter.
108+
Override `sort` and `reverse` for specific folders via config or per-folder metadata file.
92109

93110
**In `rockgarden.toml`:**
94111

@@ -98,7 +115,7 @@ sort = "date"
98115
reverse = true
99116
```
100117

101-
**In a folder's `index.md` frontmatter** (takes priority over config):
118+
**In a folder's `_folder.md` frontmatter** (takes priority over config):
102119

103120
```yaml
104121
---
@@ -107,10 +124,27 @@ sort_reverse: true
107124
---
108125
```
109126

110-
Priority: frontmatter > `[nav.overrides.<path>]` > global `[nav]` defaults.
127+
Priority: `_folder.md` > `[nav.overrides.<path>]` > global `[nav]` defaults.
111128

112129
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"`.
113130

131+
## Folder Metadata (`_folder.md`)
132+
133+
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.
134+
135+
Supported fields:
136+
137+
| Field | Type | Description |
138+
| -------------- | ------ | ------------------------------------------------------------------------- |
139+
| `nav_order` | `int` | Pin the folder's position in its parent nav (lower = higher) |
140+
| `label` | `str` | Override the folder's display label in nav and breadcrumbs |
141+
| `sort` | `str` | Child sort strategy (`alphabetical`, `files-first`, `folders-first`, `date`) |
142+
| `sort_reverse` | `bool` | Reverse the child sort order |
143+
| `unlisted` | `bool` | Hide the folder (and its descendants) from navigation entirely |
144+
| `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. |
145+
146+
`_folder.md` is strictly optional — folders work without one, using defaults.
147+
114148
## `[feed]`
115149

116150
Atom feed generation. Requires `site.base_url` to be set.
@@ -205,8 +239,7 @@ Per-page options set in YAML frontmatter:
205239
| `aliases` | `str` or `list` | Alternative names for wikilink resolution |
206240
| `author` | `str` | Page author (used in Atom feed, overrides site-level feed author) |
207241
| `subtitle` | `str` | Subtitle shown below the page title, in folder indexes, and tag indexes |
208-
| `show_index` | `bool` | For `index.md` files: render page content + auto-generated folder listing |
209-
| `unlisted` | `bool` | Hide page from sidebar navigation and folder indexes (still accessible by URL) |
242+
| `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`. |
210243

211244
## CLI Overrides
212245

site/Configuration.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,39 @@ title: Page Title # Used in nav and <title>
7676
slug: custom-slug # Override generated URL slug
7777
nav_order: 1 # Pin position in nav (lower = first)
7878
tags: [doc, guide] # Shown in folder listings
79-
show_index: true # For index.md: add folder listing
79+
unlisted: true # Hide from nav (URL still works)
8080
---
8181
```
8282

83-
### Folder Index Pages
83+
`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.
84+
85+
### Folder Metadata (`_folder.md`)
86+
87+
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.
88+
89+
```yaml
90+
---
91+
nav_order: 2 # Pin folder in parent nav
92+
label: My Folder # Folder display label override
93+
sort: alphabetical # Child sort strategy
94+
sort_reverse: false
95+
unlisted: true # Hide folder (and descendants) from nav
96+
show_index: true # Use auto-listing at /folder/ even if index.md exists
97+
---
98+
```
8499

85-
The `show_index` option controls how `index.md` files are rendered:
100+
See [[Navigation]] for full details.
101+
102+
### Folder Index Pages
86103

87-
| Scenario | Result |
88-
|----------|--------|
89-
| No `index.md` in folder | Auto-generated folder listing |
90-
| `index.md` exists (default) | Renders as normal page |
91-
| `index.md` with `show_index: true` | Page content + folder listing |
104+
A folder is served at `/folder/` when it contains any of:
92105

93-
This lets you write custom landing pages for folders while still optionally including the file listing.
106+
| Scenario | Result at `/folder/` |
107+
|----------|----------------------|
108+
| Nothing | Auto-generated folder listing |
109+
| `index.md` | `index.md` renders as a normal page |
110+
| Folder-note (`folder/folder.md`) | Equivalent to `index.md` — same URL |
111+
| `_folder.md` with `show_index: true` and `index.md` | Auto-listing with `index.md` body as prose prefix |
94112

95113
## URL Slugs
96114

@@ -116,7 +134,7 @@ slug: quickstart
116134
---
117135
```
118136

119-
This produces `/quickstart/` instead of `/getting-started/`.
137+
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).
120138

121139
## CLI Overrides
122140

site/Navigation.md

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ sort = "alphabetical" # mixed files and folders
3838
Folder display names resolve in order:
3939

4040
1. Config `labels` override
41-
2. Folder's `index.md` frontmatter title
42-
3. Folder name (titlecased)
41+
2. `label` field in the folder's `_folder.md`
42+
3. Folder's `index.md` / folder-note title
43+
4. Folder name (titlecased)
4344

4445
```toml
4546
[nav]
@@ -65,7 +66,7 @@ Each page shows a breadcrumb trail from root to current location.
6566

6667
Folders without an `index.md` automatically get a generated index page listing their contents.
6768

68-
If you create `folder/index.md`, it renders as a normal page:
69+
If you create `folder/index.md`, it renders as a normal page at `/folder/`:
6970

7071
```yaml
7172
---
@@ -75,15 +76,35 @@ title: My Folder
7576
This is a custom landing page for the folder.
7677
```
7778

78-
To show both your content and the folder listing, add `show_index: true`:
79+
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.
80+
81+
### Folder Metadata (`_folder.md`)
82+
83+
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.
7984

8085
```yaml
8186
---
82-
title: My Folder
83-
show_index: true
87+
nav_order: 2
88+
label: "My Folder"
89+
sort: alphabetical
90+
sort_reverse: false
8491
---
85-
86-
Custom intro text above the listing.
8792
```
8893

94+
Supported fields:
95+
96+
- `nav_order` — pin the folder's position in its parent nav
97+
- `label` — override the folder's display label
98+
- `sort` / `sort_reverse` — child sort strategy (per-folder override of config)
99+
- `unlisted` — hide the folder (and all its descendants) from nav
100+
- `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)
101+
102+
### Unlisted Behavior
103+
104+
`unlisted: true` on a regular page hides it from nav and folder listings (URL still works).
105+
106+
`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.
107+
108+
To hide an entire folder, set `unlisted: true` in the folder's `_folder.md`.
109+
89110
See [Examples](/examples/) for a live example with custom content.

src/rockgarden/content/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@
99
)
1010
from rockgarden.content.format_loader import load_collection_data_files, load_data_file
1111
from rockgarden.content.link_index import LinkIndex, build_link_index
12-
from rockgarden.content.loader import load_content
13-
from rockgarden.content.models import Page
12+
from rockgarden.content.loader import load_content, load_folder_metas
13+
from rockgarden.content.models import FolderMeta, Page
1414
from rockgarden.content.models_loader import resolve_model, validate_entry
1515
from rockgarden.content.store import ContentStore
1616
from rockgarden.content.strip_title import strip_content_title
1717

1818
__all__ = [
1919
"Collection",
2020
"ContentStore",
21+
"FolderMeta",
2122
"LinkIndex",
2223
"Page",
2324
"build_link_index",
@@ -27,6 +28,7 @@
2728
"load_collection_data_files",
2829
"load_content",
2930
"load_data_file",
31+
"load_folder_metas",
3032
"partition_collections",
3133
"resolve_model",
3234
"strip_content_title",

src/rockgarden/content/loader.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
import frontmatter
99

1010
from rockgarden.config import DatesConfig
11-
from rockgarden.content.models import Page
11+
from rockgarden.content.models import FolderMeta, Page
1212
from rockgarden.urls import generate_slug
1313

14+
FOLDER_META_FILENAME = "_folder.md"
15+
1416
logger = logging.getLogger(__name__)
1517

1618

@@ -152,6 +154,9 @@ def load_content(
152154
if should_ignore(path, source, ignore_patterns):
153155
continue
154156

157+
if path.name == FOLDER_META_FILENAME:
158+
continue
159+
155160
page = load_page(path, source, dates_config, url_style, ascii_urls)
156161
pages.append(page)
157162

@@ -178,3 +183,36 @@ def load_content(
178183
)
179184

180185
return pages
186+
187+
188+
def load_folder_metas(
189+
source: Path,
190+
ignore_patterns: list[str],
191+
) -> dict[str, FolderMeta]:
192+
"""Discover and load all `_folder.md` files from source directory.
193+
194+
Args:
195+
source: The source directory to scan.
196+
ignore_patterns: List of glob patterns to ignore.
197+
198+
Returns:
199+
Dict mapping folder path (relative to source, with forward slashes,
200+
empty string for the source root) to FolderMeta.
201+
"""
202+
metas: dict[str, FolderMeta] = {}
203+
204+
for path in source.rglob(FOLDER_META_FILENAME):
205+
if should_ignore(path, source, ignore_patterns):
206+
continue
207+
208+
post = frontmatter.load(path)
209+
rel_parent = path.parent.relative_to(source)
210+
folder_path = "" if rel_parent == Path(".") else rel_parent.as_posix()
211+
212+
metas[folder_path] = FolderMeta(
213+
source_path=path,
214+
folder_path=folder_path,
215+
frontmatter=dict(post.metadata),
216+
)
217+
218+
return metas

src/rockgarden/content/models.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,42 @@ def title(self) -> str:
2323
if title := self.frontmatter.get("title"):
2424
return title
2525
return self.source_path.stem.replace("_", " ")
26+
27+
28+
@dataclass
29+
class FolderMeta:
30+
"""Folder-level metadata loaded from an optional `_folder.md` file.
31+
32+
Unlike Page, FolderMeta does not produce a URL or appear in the content
33+
store or link index. Its body (if any) is ignored. Only frontmatter fields
34+
are consumed.
35+
"""
36+
37+
source_path: Path
38+
folder_path: str
39+
frontmatter: dict = field(default_factory=dict)
40+
41+
@property
42+
def nav_order(self) -> int | None:
43+
return self.frontmatter.get("nav_order")
44+
45+
@property
46+
def label(self) -> str | None:
47+
return self.frontmatter.get("label")
48+
49+
@property
50+
def sort(self) -> str | None:
51+
return self.frontmatter.get("sort")
52+
53+
@property
54+
def sort_reverse(self) -> bool | None:
55+
val = self.frontmatter.get("sort_reverse")
56+
return bool(val) if val is not None else None
57+
58+
@property
59+
def unlisted(self) -> bool:
60+
return bool(self.frontmatter.get("unlisted", False))
61+
62+
@property
63+
def show_index(self) -> bool:
64+
return bool(self.frontmatter.get("show_index", False))

src/rockgarden/nav/breadcrumbs.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dataclasses import dataclass
66

77
from rockgarden.config import NavConfig
8-
from rockgarden.content import Page
8+
from rockgarden.content import FolderMeta, Page
99
from rockgarden.nav.labels import resolve_label
1010
from rockgarden.urls import get_folder_url, get_url
1111

@@ -24,20 +24,25 @@ def build_breadcrumbs(
2424
config: NavConfig | None = None,
2525
clean_urls: bool = True,
2626
base_path: str = "",
27+
folder_metas: dict[str, FolderMeta] | None = None,
2728
) -> list[Breadcrumb]:
2829
"""Build breadcrumb trail for a page.
2930
3031
Args:
3132
page: The current page
32-
pages: All pages (for looking up folder index titles)
33+
pages: All pages (used to derive folder-index title fallback)
3334
config: Navigation config (for label overrides)
3435
clean_urls: If True, use /path/ instead of /path/index.html
36+
folder_metas: Optional dict of folder-path → FolderMeta for label
37+
resolution.
3538
3639
Returns:
3740
List of Breadcrumb objects from root to current page
3841
"""
3942
if config is None:
4043
config = NavConfig()
44+
if folder_metas is None:
45+
folder_metas = {}
4146

4247
folder_pages: dict[str, Page] = {}
4348
for p in pages:
@@ -58,7 +63,7 @@ def build_breadcrumbs(
5863

5964
breadcrumbs: list[Breadcrumb] = []
6065

61-
root_label = resolve_label("", "Home", config.labels, folder_pages)
66+
root_label = resolve_label("", "Home", config.labels, folder_metas, folder_pages)
6267
root_path = get_folder_url("", clean_urls, base_path)
6368
breadcrumbs.append(Breadcrumb(label=root_label, path=root_path))
6469

@@ -73,7 +78,9 @@ def build_breadcrumbs(
7378
folder_path = "/".join(current_path_parts)
7479

7580
original_name = original_folder_names.get(folder_path, part)
76-
label = resolve_label(folder_path, original_name, config.labels, folder_pages)
81+
label = resolve_label(
82+
folder_path, original_name, config.labels, folder_metas, folder_pages
83+
)
7784
folder_url = get_folder_url(folder_path, clean_urls, base_path)
7885
breadcrumbs.append(Breadcrumb(label=label, path=folder_url))
7986

0 commit comments

Comments
 (0)