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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ If your needs are simple - posts, pages, tags, basic navigation, and clean outpu
- **Blog posts** (with dates, tags, summaries)
- **Static pages** (About, Contact, FAQ, Projects…)
- **Optional top navigation for pages**
- **Optional sitemap.xml output** (disabled by default)
- **Automatic tag index and tag detail pages**
- **Pagination** for large post archives
- Outputs simple, portable HTML you can host anywhere:
Expand Down Expand Up @@ -96,6 +97,31 @@ Fine-tune the index with these keys:

See `docs/static_search.md` or `docs/search_spec.md` for a deeper walkthrough.

## 🗺️ Sitemap

Prefer crawlable archives? Enable the optional sitemap builder to emit a static
`sitemap.xml` alongside the rest of your output. Just provide a canonical site
URL and flip the feature switch:

```toml
[site]
url = "https://example.com"

[sitemap]
enabled = true
output = "sitemap.xml"
include_index = true
include_posts = true
include_pages = true
include_tags = true
```

The sitemap lists every published post, page, tag view, and search page (when
enabled), sorted for stable diffs. Drafts are automatically skipped, and the
default theme exposes a footer link when the feature is on. See `docs/sitemap.md`
for full configuration details, including exclusion globs and custom output
paths.

Build with overrides:

```bash
Expand Down
63 changes: 63 additions & 0 deletions docs/sitemap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 🗺️ Sitemap Support

SimplicityPress can emit a standards-compliant `sitemap.xml` so crawlers can
find every public page you publish. Generation is **off by default** and
produces no files unless explicitly enabled.

## Enable the sitemap

Set a canonical site URL (protocol + host, optional sub-path) and turn on the
feature in `site.toml`:

```toml
[site]
title = "Example"
url = "https://example.com"

[sitemap]
enabled = true
output = "sitemap.xml" # relative to output_dir
include_index = true # home + pagination
include_posts = true # /posts/<slug>/
include_pages = true # content pages + search.html (if enabled)
include_tags = true # /tags/ and per-tag detail pages
exclude_paths = [] # optional glob-style filters
```

If `sitemap.enabled = true` but `site.url` is empty, the build fails fast with a
clear error. Disabling the feature restores the old behavior—no `sitemap.xml`
is emitted and the build output is unchanged.

## What gets listed?

- Home page + pagination URLs (`/page/2/`, etc.) when `include_index` is true.
- Every published post that is not marked as a draft.
- Every Markdown page under `content/pages`.
- Tag pages (`/tags/` and `/tags/<slug>/`) when `include_tags` is true.
- The static search page if search is enabled (counts as a “page”).

Entries are sorted by final URL for deterministic diffs. Posts include a
`<lastmod>` tag using the post’s date (`YYYY-MM-DD`). Pages without a reliable
timestamp simply omit `<lastmod>`.

## Filtering unwanted paths

Use `sitemap.exclude_paths` to drop generated URLs that do not belong in the
sitemap—preview sections, draft sandboxes, etc.

```
exclude_paths = [
"posts/drafts/*",
"tags/secret/*",
]
```

Patterns are matched against the output path (no leading slash) with basic glob
rules (`*`, `?`, etc.).

## Output location & templates

`output` controls where the XML is written relative to your `output_dir`. The
default theme links to `/sitemap.xml` from the footer, guarded by the runtime
flag `site.sitemap_enabled`. If you move the file elsewhere, update your
templates to point at the new path or hide the footer link.
3 changes: 3 additions & 0 deletions docs/template_context_tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ This is a quick-reference guide for theme designers, showing exactly which varia
| `author` | dict | Values from `[author]` in site.toml |
| `nav_items` | list | Pages that opted into navigation via `show_in_nav` |

`site` also receives runtime flags such as `sitemap_enabled` so templates can
toggle UI (for example, hiding the sitemap link until it exists).

---

# 2. index.html (Home Page)
Expand Down
8 changes: 7 additions & 1 deletion docs/template_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@ Example fields:
"title": "My Blog",
"subtitle": "thoughts & notes",
"base_url": "",
"url": "https://example.com",
"language": "en",
"timezone": "UTC"
"timezone": "UTC",
"sitemap_enabled": true
}
```

SimplicityPress adds the boolean `sitemap_enabled` at build time so themes can
toggle links (the default footer hides the sitemap link until generation is
enabled).

### `author`

From `[author]` in `site.toml`:
Expand Down
10 changes: 10 additions & 0 deletions src/simplicitypress/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def init_site(site_root: Path) -> None:
title = "My SimplicityPress Site"
subtitle = ""
base_url = ""
url = ""
language = "en"
timezone = "UTC"

Expand Down Expand Up @@ -113,6 +114,15 @@ def init_site(site_root: Path) -> None:
weight_title = 8.0
weight_tags = 6.0
normalize_by_doc_len = true

[sitemap]
enabled = false
output = "sitemap.xml"
include_tags = true
include_pages = true
include_posts = true
include_index = true
exclude_paths = []
""",
),
encoding="utf-8",
Expand Down
77 changes: 75 additions & 2 deletions src/simplicitypress/core/build.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from __future__ import annotations

import re
from datetime import datetime
from math import ceil
from pathlib import Path
from typing import Callable, Optional, Sequence

from .content import discover_content
from .fs import copy_static_tree
from .models import Config, Page, Post, ProgressEvent, Stage
from .render import create_environment, render_to_file
from .search_index import SearchAssetsBuilder
from .sitemap import SitemapEntry, generate_sitemap


INDEX_FILENAME = "index.html"
Expand Down Expand Up @@ -106,6 +109,48 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N

env = create_environment(config.paths.templates_dir)

sitemap_cfg = config.sitemap or {}
sitemap_enabled = bool(sitemap_cfg.get("enabled", False))
sitemap_include_posts = bool(sitemap_cfg.get("include_posts", True))
sitemap_include_pages = bool(sitemap_cfg.get("include_pages", True))
sitemap_include_tags = bool(sitemap_cfg.get("include_tags", True))
sitemap_include_index = bool(sitemap_cfg.get("include_index", True))
sitemap_site_url: str | None = None
sitemap_output_path: Path | None = None
sitemap_exclude_patterns: Sequence[str] | None = None
sitemap_entries: list[SitemapEntry] = []

if sitemap_enabled:
site_url = str(config.site.get("url", "")).strip()
if not site_url:
raise ValueError("sitemap.enabled = true requires site.url to be set")
sitemap_site_url = site_url

raw_output = str(sitemap_cfg.get("output", "sitemap.xml") or "sitemap.xml").strip()
if not raw_output:
raw_output = "sitemap.xml"
output_path = Path(raw_output)
if output_path.is_absolute():
raise ValueError("sitemap.output must be relative to the output directory")
if any(part == ".." for part in output_path.parts):
raise ValueError("sitemap.output cannot traverse outside the output directory")
if str(output_path) in ("", "."):
output_path = Path("sitemap.xml")
sitemap_output_path = config.paths.output_dir / output_path

raw_excludes = sitemap_cfg.get("exclude_paths")
if raw_excludes is None:
sitemap_exclude_patterns = []
elif isinstance(raw_excludes, (list, tuple)):
sitemap_exclude_patterns = list(raw_excludes)
else:
raise TypeError("sitemap.exclude_paths must be a list of strings")

def add_sitemap_entry(path: str, lastmod: datetime | None = None) -> None:
if not sitemap_enabled:
return
sitemap_entries.append(SitemapEntry(path=path, lastmod=lastmod))

search_builder: SearchAssetsBuilder | None = None
search_nav_extra: list[dict[str, object]] = []
if bool(config.search.get("enabled", False)):
Expand All @@ -121,8 +166,11 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N

nav_items = _build_nav_items(pages, extra=search_nav_extra if search_nav_extra else None)

site_context = dict(config.site)
site_context["sitemap_enabled"] = sitemap_enabled

base_context: dict[str, object] = {
"site": config.site,
"site": site_context,
"author": config.author,
"nav_items": nav_items,
"search_enabled": search_builder is not None,
Expand Down Expand Up @@ -165,6 +213,9 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N
}
render_to_file(env, INDEX_FILENAME, context, output_path)

if sitemap_include_index:
add_sitemap_entry(url)

# Individual post pages.
for post in posts:
target = config.paths.output_dir / "posts" / post.slug / INDEX_FILENAME
Expand All @@ -174,6 +225,9 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N
}
render_to_file(env, "post.html", context, target)

if sitemap_include_posts and not post.draft:
add_sitemap_entry(post.url, lastmod=post.date)

# Static pages.
for page in pages:
target = config.paths.output_dir / page.slug / INDEX_FILENAME
Expand All @@ -183,6 +237,9 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N
}
render_to_file(env, "page.html", context, target)

if sitemap_include_pages:
add_sitemap_entry(page.url)

# Tags index and detail pages.
tags_data: list[dict[str, object]] = []
for tag_name, posts_for_tag in sorted(tag_index.items(), key=lambda kv: kv[0].lower()):
Expand All @@ -206,6 +263,9 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N
tags_index_target,
)

if sitemap_include_tags:
add_sitemap_entry("/tags/")

# Tag detail pages.
for tag_entry in tags_data:
tag_name = str(tag_entry["name"])
Expand All @@ -220,12 +280,15 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N
}
render_to_file(env, "tag.html", context, target)

if sitemap_include_tags:
add_sitemap_entry(str(tag_entry["url"]))

# RSS/Atom-style feed (RSS 2.0 for now).
feed_items = int(config.build.get("feed_items", 20)) or 20
recent_posts = posts[:feed_items]
feed_target = config.paths.output_dir / "feed.xml"
feed_context: dict[str, object] = {
"site": config.site,
"site": site_context,
"author": config.author,
"posts": recent_posts,
}
Expand All @@ -234,11 +297,21 @@ def emit(stage: Stage, current: int = 0, total: int = 1, message: str = "") -> N

if search_builder is not None:
search_builder.build_assets(posts, pages, env, base_context)
if sitemap_include_pages:
add_sitemap_entry(search_builder.page_url)

# Static assets.
emit(Stage.COPYING_STATIC, current=0, total=1, message="Copying static assets")
static_dir = config.paths.static_dir
output_static_dir = config.paths.output_dir / "static"
copy_static_tree(static_dir, output_static_dir)

if sitemap_enabled and sitemap_site_url and sitemap_output_path:
generate_sitemap(
sitemap_entries,
site_url=sitemap_site_url,
output_path=sitemap_output_path,
exclude_patterns=sitemap_exclude_patterns,
)

emit(Stage.DONE, current=1, total=1, message="Build completed")
1 change: 1 addition & 0 deletions src/simplicitypress/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@ def load_config(site_root: Path) -> Config:
build=merged.get("build", {}),
author=merged.get("author", {}),
search=merged.get("search", {}),
sitemap=merged.get("sitemap", {}),
paths=site_paths,
)
10 changes: 10 additions & 0 deletions src/simplicitypress/core/default_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"title": "My Site",
"subtitle": "",
"base_url": "",
"url": "",
"language": "en",
"timezone": "UTC",
},
Expand Down Expand Up @@ -44,4 +45,13 @@
"weight_tags": 6.0,
"normalize_by_doc_len": True,
},
"sitemap": {
"enabled": False,
"output": "sitemap.xml",
"include_tags": True,
"include_pages": True,
"include_posts": True,
"include_index": True,
"exclude_paths": [],
},
}
1 change: 1 addition & 0 deletions src/simplicitypress/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Config:
build: dict
author: dict
search: dict
sitemap: dict
paths: SitePaths


Expand Down
Loading