Skip to content

Commit 11d74fe

Browse files
committed
feat: switch to pydantic for modeling
1 parent 74e93e1 commit 11d74fe

6 files changed

Lines changed: 198 additions & 144 deletions

File tree

plans/concepts.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ my-site/
1111
├── _themes/<name>/ # complete theme packages
1212
├── _plugins/<name>/ # behavioral extensions (future)
1313
├── _macros/ # reusable Jinja2 snippets (planned — Feature 09)
14+
├── _models/ # Pydantic models for typed collection entry validation
1415
├── _styles/ # custom CSS (planned — Feature 16)
1516
├── _scripts/ # custom JS (planned — Feature 16)
1617
└── content/
@@ -59,6 +60,18 @@ Hooks can be declared directly in `rockgarden.toml` under `[hooks]`, or provided
5960

6061
Status: planned (Feature 15, Phase B).
6162

63+
## Model
64+
65+
An optional Pydantic `BaseModel` subclass that defines the schema for collection entries.
66+
67+
- Lives in `_models/<name>.py` (site-level) or `_themes/<name>/_models/<name>.py` (theme-provided)
68+
- Class name is the title-cased filename (`speaker.py``Speaker`)
69+
- Referenced by `model = "speaker"` in a `[[collections]]` config block
70+
- Resolution cascade: site-level overrides theme-provided (same pattern as templates)
71+
- Without a model, collection entries are plain dicts; with a model, entries are validated on load
72+
73+
Status: planned (Feature 14, Phase B).
74+
6275
## Macro
6376

6477
A reusable Jinja2 template snippet.

plans/features/14-collections.md

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ Collections are progressively configurable:
1212

1313
```
1414
Just a name + source → namespace/grouping only, renders markdown pages normally
15-
+ model → schema/field expectations
1615
+ formats → load YAML/JSON/TOML in addition to markdown
1716
+ template/url → custom page generation
1817
+ pages = false → data-only, queryable but no output
1918
+ nav = true → appears in sidebar
19+
+ model → typed entry validation via Pydantic BaseModel
2020
```
2121

2222
## Use Cases
@@ -57,15 +57,10 @@ url_pattern = "/speakers/{slug}/"
5757
name = "schedule"
5858
source = "_data/schedule"
5959
pages = false
60-
61-
# Optional models
62-
[models.pc]
63-
fields = ["name", "class", "level", "race"]
64-
65-
[models.npc]
66-
fields = ["name", "location", "faction"]
6760
```
6861

62+
Models are defined as Python files, not in TOML — see [Content Models](#content-models) below.
63+
6964
## Nesting Behavior
7065

7166
Collections can nest. Content in a nested collection belongs to all parent collections:
@@ -86,11 +81,34 @@ This gives "NPC extends Character" behavior without explicit inheritance — it
8681
| `url_pattern` | no || URL pattern with `{field}` placeholders |
8782
| `pages` | no | `true` | Whether to generate pages (requires template + url_pattern) |
8883
| `nav` | no | `false` | Whether generated pages appear in sidebar nav |
89-
| `model` | no || Name of a model from `[models.*]` config |
84+
| `model` | no || Model name — resolved to a Pydantic BaseModel via cascade (see below) |
85+
86+
## Content Models
87+
88+
Models are optional Python files containing a Pydantic `BaseModel` subclass. When a collection has `model = "speaker"`, rockgarden looks for a `Speaker` class using this cascade (first match wins):
89+
90+
1. `_models/speaker.py` — site-level (highest priority)
91+
2. `_themes/<active-theme>/_models/speaker.py` — theme-provided
92+
93+
Class name is the title-cased filename (`speaker.py``Speaker`).
94+
95+
Example:
96+
97+
```python
98+
# _models/speaker.py
99+
from pydantic import BaseModel
100+
101+
class Speaker(BaseModel):
102+
name: str
103+
bio: str = ""
104+
photo: str | None = None
105+
```
106+
107+
Without a model, collection entries are plain dicts. With a model, entries are validated and coerced on load — missing required fields produce a build error.
90108

91109
## Data Layer
92110

93-
The `ContentStore` stays in-memory (Python dicts/dataclasses) and becomes collection-aware. No external database required.
111+
The `ContentStore` stays in-memory and becomes collection-aware. No external database required.
94112

95113
```python
96114
store.list_content() # default collection
@@ -113,22 +131,26 @@ For hook scripts that need access to collected data, content is exported to JSON
113131
### 1. Config
114132

115133
```python
116-
@dataclass
117-
class CollectionConfig:
134+
class CollectionConfig(BaseModel):
118135
name: str
119136
source: str
120137
template: str | None = None
121138
url_pattern: str | None = None
122139
pages: bool = True
123140
nav: bool = False
124141
model: str | None = None
142+
```
125143

126-
@dataclass
127-
class ModelConfig:
128-
fields: list[str]
144+
Added to the root `Config` model:
145+
```python
146+
collections: list[CollectionConfig] = Field(default_factory=list)
129147
```
130148

131-
### 2. Collection-Aware ContentStore
149+
### 2. Model Resolution
150+
151+
A `resolve_model(name, config, theme_dir)` function that walks the cascade and returns the Pydantic BaseModel class (or `None` if no model file is found).
152+
153+
### 3. Collection-Aware ContentStore
132154

133155
Extend `ContentStore` to track collection membership. Without config, all content is in the default collection. Named collections carve out subsets.
134156

@@ -142,28 +164,28 @@ class ContentStore:
142164
"""Export all collections to JSON for hook scripts."""
143165
```
144166

145-
### 3. Collection Loader
167+
### 4. Collection Loader
146168

147169
Load files from collection source directories. Supported formats:
148170
- `.yaml` / `.yml` — parsed as dict of fields
149171
- `.json` — parsed as dict of fields
150172
- `.toml` — parsed as dict of fields
151173
- `.md` — frontmatter as fields, body rendered as HTML and available as `content`
152174

153-
Each entry gets a `slug` derived from filename (or overridden by a `slug` field).
175+
Each entry gets a `slug` derived from filename (or overridden by a `slug` field). If a model is configured, entries are validated through it on load.
154176

155-
### 4. Template Context
177+
### 5. Template Context
156178

157-
All collections available in every template as `collections.<name>` (list of dicts). Jinja2's built-in filters handle querying.
179+
All collections available in every template as `collections.<name>` (list of dicts or model instances serialized to dict). Jinja2's built-in filters handle querying.
158180

159-
### 5. Page Generation
181+
### 6. Page Generation
160182

161183
For collections with `pages = true` and a `template` + `url_pattern`:
162184
- Generate one page per entry
163185
- URL from `url_pattern` with `{field}` replaced by entry fields
164186
- Render using specified template with entry data in context
165187

166-
### 6. Nav Integration
188+
### 7. Nav Integration
167189

168190
For collections with `nav = true`:
169191
- Add generated pages to nav tree
@@ -172,13 +194,14 @@ For collections with `nav = true`:
172194
## Key Files to Create/Modify
173195

174196
- `content/store.py` — Extend with collection awareness and JSON export
175-
- `content/collection.py` — New module: collection loading, format parsing
176-
- `config.py` — Add `CollectionConfig`, `ModelConfig` dataclasses
197+
- `content/collection.py` — New module: collection loading, format parsing, model resolution
198+
- `config.py` — Add `CollectionConfig` Pydantic BaseModel; add `collections` field to `Config`
177199
- `output/builder.py` — Load collections, generate pages, pass to templates
178200
- `render/engine.py` — Add collections to template context
179201

180202
## Dependencies
181203

204+
- `pydantic>=2.0` (added for config migration)
182205
- `pyyaml` (already used by python-frontmatter)
183206
- `tomllib` (stdlib in 3.11+)
184207
- `json` (stdlib)

plans/future.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Noted but not currently planned:
2020
- **Configurable reserved directory names**: The special directories `_templates/`, `_themes/`, and `_static/` (output) are currently hardcoded. These should be configurable via `[build]` config with the current names as documented defaults. Useful for sites where those names conflict with content. (`_site` output is already configurable via `[site] output`.)
2121
- **Extract icon handling**: Move icon resolution (`rockgarden.icons`) into a standalone generic Jinja icons package. Current implementation is bordering on out-of-scope for a static site generator.
2222

23+
- **Theme manifest collection defaults**: A `theme.toml` in a theme directory that declares per-collection defaults (`template`, `url_pattern`, `model`). Sites using the theme would only need to provide `source` in their `[[collections]]` config. Deferred from Phase 2 — currently sites must declare all collection fields explicitly, and themes provide model classes via `_themes/<name>/_models/` cascade.
24+
2325
Moved to roadmap:
2426
- ~~Content from other data sources~~ → Feature 14 (Collections) + Feature 15 (Build Hooks)
2527

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"python-frontmatter>=1.0.0",
2626
"linkify-it-py>=2.0.3",
2727
"tzdata>=2024.1",
28+
"pydantic>=2.12.5",
2829
]
2930

3031
[project.scripts]

0 commit comments

Comments
 (0)