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
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ A Python static site generator that works with Obsidian vaults and plain markdow

- [[Architecture]] - Build pipeline and module structure
- [[Deployment]] - Hosting and CI/CD
- [[markdown-support|Markdown Support]] - Supported syntax reference
- [[Markdown Support]] - Supported syntax reference
- [[Vision]] - Goals and design philosophy
2 changes: 2 additions & 0 deletions docs/rockgarden.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ title = "Rockgarden"
clean_urls = true
output = "../_site"
ignore_patterns = []

[dates]
timezone = "US/Eastern"

[nav]
Expand Down
148 changes: 148 additions & 0 deletions plans/features/N11-config-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Feature N11: Config Validation

## Status: Not Started (Phase B)

## Goal

Provide a `validate` CLI command that checks a `rockgarden.toml` for problems: unknown keys (likely typos), invalid values, missing required values, and theme-specific config issues. Themes should be able to declare what config keys they use so that unexpected or missing theme config can also be reported.

---

## Design

### Validation Categories

**Errors** (exit code 1):
- TOML syntax errors
- Invalid timezone (ZoneInfo lookup fails)
- Theme declared as required config missing from TOML

**Warnings** (exit code 0, printed to stderr):
- Unknown key in any config section (probable typo)
- Source directory does not exist
- `theme.name` set but `_themes/<name>/` directory not found
- Theme manifest declares a required key but it's absent from `[theme]`

### Known-Key Validation

The existing dataclasses define all valid keys per section. Validation extracts field names via `dataclasses.fields()` and compares against what the TOML dict actually contains. Unknown keys get a warning.

Known sections: `site`, `build`, `theme`, `nav`, `toc`, `search`, `dates`. Unknown top-level sections also warn.

### Theme Manifest (`theme.toml`)

A theme can optionally ship a `theme.toml` in its directory (`_themes/<name>/theme.toml`) to declare metadata and any additional config keys it reads from the `[theme]` section.

```toml
[theme]
name = "pyohio"
description = "PyOhio conference theme"
version = "1.0.0"

[[theme.config]]
key = "show_sponsors"
type = "bool"
required = true
description = "Whether to show sponsor logos in the footer"

[[theme.config]]
key = "primary_color"
type = "string"
required = false
default = "#3490dc"
description = "Primary brand color (CSS value)"
```

When a theme manifest exists:
- Its declared keys are added to the known-key set for `[theme]`, so they don't produce "unknown key" warnings
- Any key marked `required = true` that is absent from `[theme]` produces a warning
- Themes without a manifest are still valid — manifest is optional

---

## Implementation Plan

### 1. New module: `src/rockgarden/validation.py`

```python
@dataclass
class ValidationIssue:
level: Literal["error", "warning"]
message: str

def validate_config(config_dict: dict, source_dir: Path | None = None) -> list[ValidationIssue]:
"""Validate a parsed TOML dict against known config schema."""
...

def load_theme_manifest(theme_dir: Path) -> dict:
"""Load and parse theme.toml from a theme directory. Returns {} if absent."""
...
```

Key checks in `validate_config()`:
- Unknown top-level sections
- Unknown keys per section (via `dataclasses.fields()` on each config class)
- Timezone: `ZoneInfo(dates_data.get("timezone", "UTC"))` in try/except
- Source dir existence (if provided / resolvable)
- Theme dir existence + manifest loading if `theme.name` is set
- Theme manifest required-key check

### 2. New CLI command in `src/rockgarden/cli.py`

```python
@app.command()
def validate(
source: Annotated[Path | None, typer.Option("--source", "-s")] = None,
config_file: Annotated[Path | None, typer.Option("--config", "-c")] = None,
) -> None:
"""Validate rockgarden configuration."""
```

Same config/source resolution logic as `build` (auto-discovers `rockgarden.toml` in source dir). Reports issues to stderr, exits 1 on any errors.

Output format:
```
✓ No issues found.

# or:

Warning: [site] unknown key "titl" (did you mean "title"?)
Warning: source directory "content" does not exist
Error: [dates] invalid timezone "US/Easten" — ZoneInfoNotFoundError
```

### 3. Known-key sets

Rather than hard-coding lists, derive from dataclasses at runtime:

```python
import dataclasses
from rockgarden.config import SiteConfig, BuildConfig, ThemeConfig, ...

KNOWN_KEYS = {
"site": {f.name for f in dataclasses.fields(SiteConfig)},
"build": {f.name for f in dataclasses.fields(BuildConfig)},
...
}
```

Built-in `ThemeConfig` fields are always valid. Theme manifest declared keys are added on top.

---

## Key Files

- **New**: `src/rockgarden/validation.py`
- **Modified**: `src/rockgarden/cli.py` — add `validate` command
- **New (per-theme, optional)**: `_themes/<name>/theme.toml`

---

## Verification

- `rockgarden validate` on a clean config → exits 0, "No issues found"
- Introduce a typo (`titl = "foo"`) → warning reported
- Set `timezone = "Bad/Zone"` → error reported, exit 1
- Set `theme.name` to nonexistent dir → warning reported
- Create a theme manifest with a required key, omit it from TOML → warning reported
- `uv run pytest` still passes (no regressions)
1 change: 1 addition & 0 deletions plans/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ The [PyOhio static website](https://github.com/pyohio/static-website) (Astro + P
| N8 | Tag Index Pages | ❌ | B | Generate `/tags/<tag>/` listing pages |
| N9 | Template Decomposition | ✅ | A | Named blocks as customization hooks in page templates |
| N10 | Newline Handling | ✅ | A | Obsidian-style single newline → `<br>` |
| N11 | [Config Validation](N11-config-validation.md) | ❌ | B | `validate` command, unknown-key warnings, theme manifest |

## Roadmap Phases

Expand Down
1 change: 1 addition & 0 deletions plans/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ After each step, verify incrementally:
- [ ] **Graph View**: Interactive visualization of page connections
- Requirements to be workshopped and defined

- [ ] Config validation command + theme manifest (N11)
- [ ] Collections and content models (Feature 14)
- [ ] Build hooks (Feature 15)
- [ ] Base path prefix support (Feature 12)
Expand Down
8 changes: 4 additions & 4 deletions src/rockgarden/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ class SiteConfig:
output: Path = field(default_factory=lambda: Path("_site"))
clean_urls: bool = True
base_url: str = ""
timezone: str = "UTC"


@dataclass
Expand Down Expand Up @@ -87,7 +86,8 @@ class DatesConfig:
created_date_fields: list[str] = field(
default_factory=lambda: ["created", "date", "date_created"]
)
modified_date_fallback: bool = True
modified_date_fallback: bool = False
timezone: str = "UTC"


@dataclass
Expand Down Expand Up @@ -141,7 +141,6 @@ def from_dict(cls, data: dict) -> "Config":
output=Path(site_data.get("output", "_site")),
clean_urls=site_data.get("clean_urls", True),
base_url=site_data.get("base_url", "").rstrip("/"),
timezone=site_data.get("timezone", "UTC"),
)

icons_dir_raw = build_data.get("icons_dir")
Expand Down Expand Up @@ -191,8 +190,9 @@ def from_dict(cls, data: dict) -> "Config":
dates_defaults.created_date_fields,
),
modified_date_fallback=dates_data.get(
"modified_date_fallback", True
"modified_date_fallback", False
),
timezone=dates_data.get("timezone", "UTC"),
)

return cls(
Expand Down
6 changes: 2 additions & 4 deletions src/rockgarden/output/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,7 @@ def build_site(config: Config, source: Path, output: Path) -> BuildResult:

content = page.content

if page.frontmatter.get("title"):
content = strip_content_title(content)
content = strip_content_title(content)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: strip_content_title is now called unconditionally for all pages, including those with no frontmatter title. For a page like analysis.md starting with # Introduction (a section heading, not the document title), that heading will be silently stripped while the template's {% block heading %} separately renders <h1>analysis</h1> from the filename. The existing tests only cover the case where the leading H1 matches the derived title (my-page.md + # My Page). Previously the guard if page.frontmatter.get("title") ensured stripping only happened when the template title and content H1 were guaranteed to be the same heading.


page_rel_path = str(page.source_path.relative_to(source))
media_resolver = create_media_resolver(source, page_rel_path, media_index)
Expand Down Expand Up @@ -257,8 +256,7 @@ def build_site(config: Config, source: Path, output: Path) -> BuildResult:

if folder.custom_content:
processed = folder.custom_content
if folder.frontmatter.get("title"):
processed = strip_content_title(processed)
processed = strip_content_title(processed)
folder_src = folder_path + "/index.md" if folder_path else "index.md"
media_resolver = create_media_resolver(source, folder_src, media_index)
processed, media = process_media_embeds(processed, media_resolver)
Expand Down
2 changes: 1 addition & 1 deletion src/rockgarden/render/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def create_engine(
loader=ChoiceLoader(loaders),
autoescape=True,
)
env.filters["format_datetime"] = _make_format_datetime(config.site.timezone)
env.filters["format_datetime"] = _make_format_datetime(config.dates.timezone)
return env


Expand Down
2 changes: 1 addition & 1 deletion src/rockgarden/static/rockgarden.css

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions src/rockgarden/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,14 @@
</div>

<!-- Main content -->
<main id="main-content" class="flex-1 p-4 lg:p-8">
<main id="main-content" class="flex-1 p-4 lg:p-8 lg:min-h-screen">
<div class="max-w-4xl mx-auto">

This comment was marked as outdated.

{% block content %}{% endblock %}
{% block footer %}
{% if site.build_info %}
{% include "components/footer.html" %}
{% endif %}
{% endblock %}
</div>
</main>
</div>
Expand Down Expand Up @@ -95,11 +100,6 @@
</div>
{% endif %}

{% block footer %}
{% if site.build_info %}
{% include "components/footer.html" %}
{% endif %}
{% endblock %}
<script>
// Theme switcher functionality
(function() {
Expand Down
2 changes: 1 addition & 1 deletion src/rockgarden/templates/components/footer.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<footer class="text-xs text-base-content/40 text-center py-6 mt-8 border-t border-base-200">
<footer class="text-xs text-base-content/40 py-6 mt-8 border-t border-base-200">
<div class="flex flex-col gap-1">
<div>Last built: {{ site.build_info.build_time | format_datetime('%m/%d/%Y %I:%M:%S %p %Z') }}</div>
{% if site.build_info.git_commit %}
Expand Down
30 changes: 30 additions & 0 deletions tests/test_strip_title.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Tests for stripping H1 from content."""

from rockgarden.content import strip_content_title
from rockgarden.output.builder import build_site
from rockgarden.config import Config


class TestStripContentTitle:
Expand Down Expand Up @@ -43,3 +45,31 @@ def test_only_h1(self):
content = "# Just a Title"
result = strip_content_title(content)
assert result == ""


class TestBuildSiteStripTitle:
"""Test that build_site strips H1 from page output."""

def test_strips_h1_when_title_in_frontmatter(self, tmp_path):
"""H1 should not appear twice when title is set in frontmatter."""
source = tmp_path / "source"
source.mkdir()
(source / "page.md").write_text("---\ntitle: My Page\n---\n\n# My Page\n\nSome content.")

output = tmp_path / "output"
build_site(Config.load(None), source, output)

html = (output / "page" / "index.html").read_text()
assert html.count("<h1") == 1

def test_strips_h1_when_title_derived_from_filename(self, tmp_path):
"""H1 should not appear twice when title is derived from filename (no frontmatter)."""
source = tmp_path / "source"
source.mkdir()
(source / "my-page.md").write_text("# My Page\n\nSome content.")

output = tmp_path / "output"
build_site(Config.load(None), source, output)

html = (output / "my-page" / "index.html").read_text()
assert html.count("<h1") == 1