ursaproxy 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ursaproxy
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Bearblog-to-Gemini proxy showcasing Xitzin
5
5
  Author: Alan Velasco
6
6
  Author-email: Alan Velasco <ursaproxy@alanbato.com>
@@ -44,47 +44,93 @@ pip install ursaproxy
44
44
 
45
45
  ## Configuration
46
46
 
47
- UrsaProxy is configured via environment variables:
47
+ UrsaProxy can be configured via a TOML file or environment variables.
48
48
 
49
- ### Required
49
+ ### TOML Configuration (Recommended)
50
50
 
51
- | Variable | Description |
52
- |----------|-------------|
53
- | `BEARBLOG_URL` | The Bearblog URL to proxy (e.g., `https://example.bearblog.dev`) |
54
- | `BLOG_NAME` | Display name for the blog |
55
- | `CERT_FILE` | Path to TLS certificate file |
56
- | `KEY_FILE` | Path to TLS private key file |
51
+ Create an `ursaproxy.toml` file in your working directory:
57
52
 
58
- ### Optional
53
+ ```toml
54
+ bearblog_url = "https://example.bearblog.dev"
55
+ blog_name = "My Gemini Blog"
56
+ cert_file = "/path/to/cert.pem"
57
+ key_file = "/path/to/key.pem"
59
58
 
60
- | Variable | Default | Description |
61
- |----------|---------|-------------|
62
- | `PAGES` | `{}` | JSON dict of static pages `{"slug": "Title"}` |
63
- | `GEMINI_HOST` | `None` | Hostname for Gemini URLs in feed |
64
- | `CACHE_TTL_FEED` | `300` | Feed cache TTL in seconds (5 min) |
65
- | `CACHE_TTL_POST` | `1800` | Post cache TTL in seconds (30 min) |
66
- | `HOST` | `localhost` | Server bind address |
67
- | `PORT` | `1965` | Server port (Gemini default) |
59
+ # Optional settings
60
+ gemini_host = "gemini.example.com"
61
+ cache_ttl_feed = 300
62
+ cache_ttl_post = 1800
63
+ host = "localhost"
64
+ port = 1965
68
65
 
69
- ### Example
70
-
71
- ```bash
72
- export BEARBLOG_URL="https://example.bearblog.dev"
73
- export BLOG_NAME="My Gemini Blog"
74
- export CERT_FILE="/path/to/cert.pem"
75
- export KEY_FILE="/path/to/key.pem"
76
- export PAGES='{"about": "About Me", "now": "What I am doing now"}'
77
- export GEMINI_HOST="gemini.example.com"
66
+ [pages]
67
+ about = "About Me"
68
+ now = "What I am doing now"
78
69
  ```
79
70
 
71
+ ### Environment Variables
72
+
73
+ Environment variables override TOML settings:
74
+
75
+ | Variable | Required | Default | Description |
76
+ |----------|----------|---------|-------------|
77
+ | `BEARBLOG_URL` | Yes | - | The Bearblog URL to proxy |
78
+ | `BLOG_NAME` | Yes | - | Display name for the blog |
79
+ | `CERT_FILE` | No | `None` | Path to TLS certificate file |
80
+ | `KEY_FILE` | No | `None` | Path to TLS private key file |
81
+ | `PAGES` | No | `{}` | JSON dict of static pages |
82
+ | `GEMINI_HOST` | No | `None` | Hostname for Gemini URLs in feed |
83
+ | `CACHE_TTL_FEED` | No | `300` | Feed cache TTL in seconds |
84
+ | `CACHE_TTL_POST` | No | `1800` | Post cache TTL in seconds |
85
+ | `HOST` | No | `localhost` | Server bind address |
86
+ | `PORT` | No | `1965` | Server port |
87
+
88
+ ### Configuration Priority
89
+
90
+ Settings are loaded in this order (highest to lowest priority):
91
+
92
+ 1. Environment variables
93
+ 2. `ursaproxy.toml` file
94
+ 3. Default values
95
+
80
96
  ## Usage
81
97
 
98
+ ### Standalone
99
+
82
100
  ```bash
83
101
  ursaproxy
84
102
  ```
85
103
 
86
104
  The server will start on `gemini://localhost:1965/` by default.
87
105
 
106
+ ### As a Library
107
+
108
+ UrsaProxy can be embedded in other Xitzin applications for virtual hosting:
109
+
110
+ ```python
111
+ from ursaproxy import create_app, Settings
112
+
113
+ # Create settings programmatically
114
+ settings = Settings(
115
+ bearblog_url="https://myblog.bearblog.dev",
116
+ blog_name="My Blog",
117
+ )
118
+
119
+ # Create the app
120
+ app = create_app(settings)
121
+
122
+ # Mount in your main Xitzin app or run directly
123
+ app.run(certfile="cert.pem", keyfile="key.pem")
124
+ ```
125
+
126
+ Or load settings from TOML/environment:
127
+
128
+ ```python
129
+ from ursaproxy import create_app, load_settings
130
+
131
+ app = create_app(load_settings())
132
+ ```
133
+
88
134
  ### Routes
89
135
 
90
136
  | Route | Description |
@@ -151,7 +197,7 @@ src/ursaproxy/
151
197
  The test suite uses pytest with fixtures for offline testing:
152
198
 
153
199
  ```bash
154
- # Run all 111 tests
200
+ # Run all tests
155
201
  uv run pytest
156
202
 
157
203
  # Run specific test file
@@ -24,47 +24,93 @@ pip install ursaproxy
24
24
 
25
25
  ## Configuration
26
26
 
27
- UrsaProxy is configured via environment variables:
27
+ UrsaProxy can be configured via a TOML file or environment variables.
28
28
 
29
- ### Required
29
+ ### TOML Configuration (Recommended)
30
30
 
31
- | Variable | Description |
32
- |----------|-------------|
33
- | `BEARBLOG_URL` | The Bearblog URL to proxy (e.g., `https://example.bearblog.dev`) |
34
- | `BLOG_NAME` | Display name for the blog |
35
- | `CERT_FILE` | Path to TLS certificate file |
36
- | `KEY_FILE` | Path to TLS private key file |
31
+ Create an `ursaproxy.toml` file in your working directory:
37
32
 
38
- ### Optional
33
+ ```toml
34
+ bearblog_url = "https://example.bearblog.dev"
35
+ blog_name = "My Gemini Blog"
36
+ cert_file = "/path/to/cert.pem"
37
+ key_file = "/path/to/key.pem"
39
38
 
40
- | Variable | Default | Description |
41
- |----------|---------|-------------|
42
- | `PAGES` | `{}` | JSON dict of static pages `{"slug": "Title"}` |
43
- | `GEMINI_HOST` | `None` | Hostname for Gemini URLs in feed |
44
- | `CACHE_TTL_FEED` | `300` | Feed cache TTL in seconds (5 min) |
45
- | `CACHE_TTL_POST` | `1800` | Post cache TTL in seconds (30 min) |
46
- | `HOST` | `localhost` | Server bind address |
47
- | `PORT` | `1965` | Server port (Gemini default) |
39
+ # Optional settings
40
+ gemini_host = "gemini.example.com"
41
+ cache_ttl_feed = 300
42
+ cache_ttl_post = 1800
43
+ host = "localhost"
44
+ port = 1965
48
45
 
49
- ### Example
50
-
51
- ```bash
52
- export BEARBLOG_URL="https://example.bearblog.dev"
53
- export BLOG_NAME="My Gemini Blog"
54
- export CERT_FILE="/path/to/cert.pem"
55
- export KEY_FILE="/path/to/key.pem"
56
- export PAGES='{"about": "About Me", "now": "What I am doing now"}'
57
- export GEMINI_HOST="gemini.example.com"
46
+ [pages]
47
+ about = "About Me"
48
+ now = "What I am doing now"
58
49
  ```
59
50
 
51
+ ### Environment Variables
52
+
53
+ Environment variables override TOML settings:
54
+
55
+ | Variable | Required | Default | Description |
56
+ |----------|----------|---------|-------------|
57
+ | `BEARBLOG_URL` | Yes | - | The Bearblog URL to proxy |
58
+ | `BLOG_NAME` | Yes | - | Display name for the blog |
59
+ | `CERT_FILE` | No | `None` | Path to TLS certificate file |
60
+ | `KEY_FILE` | No | `None` | Path to TLS private key file |
61
+ | `PAGES` | No | `{}` | JSON dict of static pages |
62
+ | `GEMINI_HOST` | No | `None` | Hostname for Gemini URLs in feed |
63
+ | `CACHE_TTL_FEED` | No | `300` | Feed cache TTL in seconds |
64
+ | `CACHE_TTL_POST` | No | `1800` | Post cache TTL in seconds |
65
+ | `HOST` | No | `localhost` | Server bind address |
66
+ | `PORT` | No | `1965` | Server port |
67
+
68
+ ### Configuration Priority
69
+
70
+ Settings are loaded in this order (highest to lowest priority):
71
+
72
+ 1. Environment variables
73
+ 2. `ursaproxy.toml` file
74
+ 3. Default values
75
+
60
76
  ## Usage
61
77
 
78
+ ### Standalone
79
+
62
80
  ```bash
63
81
  ursaproxy
64
82
  ```
65
83
 
66
84
  The server will start on `gemini://localhost:1965/` by default.
67
85
 
86
+ ### As a Library
87
+
88
+ UrsaProxy can be embedded in other Xitzin applications for virtual hosting:
89
+
90
+ ```python
91
+ from ursaproxy import create_app, Settings
92
+
93
+ # Create settings programmatically
94
+ settings = Settings(
95
+ bearblog_url="https://myblog.bearblog.dev",
96
+ blog_name="My Blog",
97
+ )
98
+
99
+ # Create the app
100
+ app = create_app(settings)
101
+
102
+ # Mount in your main Xitzin app or run directly
103
+ app.run(certfile="cert.pem", keyfile="key.pem")
104
+ ```
105
+
106
+ Or load settings from TOML/environment:
107
+
108
+ ```python
109
+ from ursaproxy import create_app, load_settings
110
+
111
+ app = create_app(load_settings())
112
+ ```
113
+
68
114
  ### Routes
69
115
 
70
116
  | Route | Description |
@@ -131,7 +177,7 @@ src/ursaproxy/
131
177
  The test suite uses pytest with fixtures for offline testing:
132
178
 
133
179
  ```bash
134
- # Run all 111 tests
180
+ # Run all tests
135
181
  uv run pytest
136
182
 
137
183
  # Run specific test file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ursaproxy"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "A Bearblog-to-Gemini proxy showcasing Xitzin"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,234 @@
1
+ from datetime import datetime
2
+ from email.utils import parsedate_to_datetime
3
+
4
+ import httpx
5
+ from jinja2 import Environment, PackageLoader
6
+ from xitzin import NotFound, Request, Response, TemporaryFailure, Xitzin
7
+
8
+ from .cache import cache
9
+ from .config import Settings, load_settings
10
+ from .converter import extract_metadata, extract_slug, html_to_gemtext
11
+ from .fetcher import NotFoundError, ServerError, fetch_feed, fetch_html
12
+
13
+ __all__ = ["create_app", "Settings", "load_settings"]
14
+
15
+
16
+ def _rfc822_to_iso(date_str: str) -> str:
17
+ """Convert RFC 822 date to ISO 8601 format for Atom feeds."""
18
+ if not date_str:
19
+ return datetime.now().isoformat() + "Z"
20
+ try:
21
+ dt = parsedate_to_datetime(date_str)
22
+ return dt.isoformat().replace("+00:00", "Z")
23
+ except (ValueError, TypeError):
24
+ return datetime.now().isoformat() + "Z"
25
+
26
+
27
+ # Template environments
28
+ templates = Environment(
29
+ loader=PackageLoader("ursaproxy", "templates"),
30
+ autoescape=False, # Gemtext doesn't need HTML escaping
31
+ )
32
+
33
+ xml_templates = Environment(
34
+ loader=PackageLoader("ursaproxy", "templates"),
35
+ autoescape=True, # XML escaping for feed
36
+ )
37
+
38
+
39
+ def create_app(settings: Settings) -> Xitzin:
40
+ """Create and configure an UrsaProxy application instance.
41
+
42
+ This factory function allows ursaproxy to be embedded in other Xitzin apps
43
+ for virtual hosting scenarios.
44
+
45
+ Args:
46
+ settings: Configuration for this ursaproxy instance.
47
+
48
+ Returns:
49
+ A configured Xitzin application.
50
+
51
+ Example:
52
+ For standalone use::
53
+
54
+ from ursaproxy import create_app, load_settings
55
+
56
+ app = create_app(load_settings())
57
+ app.run()
58
+
59
+ For embedding in another app::
60
+
61
+ from ursaproxy import create_app, Settings
62
+
63
+ ursaproxy_settings = Settings(
64
+ bearblog_url="https://myblog.bearblog.dev",
65
+ blog_name="My Blog",
66
+ )
67
+ ursaproxy_app = create_app(ursaproxy_settings)
68
+ # Mount ursaproxy_app in your main Xitzin app
69
+ """
70
+ app = Xitzin()
71
+ app.state.settings = settings
72
+
73
+ @app.on_startup
74
+ async def startup() -> None:
75
+ """Initialize shared HTTP client."""
76
+ app.state.client = httpx.AsyncClient(timeout=30.0)
77
+
78
+ @app.on_shutdown
79
+ async def shutdown() -> None:
80
+ """Close HTTP client."""
81
+ await app.state.client.aclose()
82
+
83
+ async def _get_feed(client: httpx.AsyncClient):
84
+ """Fetch feed with caching and error handling."""
85
+ if cached := cache.get("feed", settings.cache_ttl_feed):
86
+ return cached
87
+
88
+ try:
89
+ feed = await fetch_feed(client, settings.bearblog_url)
90
+ cache.set("feed", feed)
91
+ return feed
92
+ except ServerError as e:
93
+ raise TemporaryFailure(str(e)) from e
94
+ except NotFoundError as e:
95
+ raise NotFound(str(e)) from e
96
+
97
+ async def _render_content(
98
+ client: httpx.AsyncClient,
99
+ slug: str,
100
+ content_type: str,
101
+ include_date: bool = True,
102
+ ) -> str:
103
+ """Fetch and render content as gemtext with caching."""
104
+ cache_key = f"{content_type}:{slug}"
105
+
106
+ if cached := cache.get(cache_key, settings.cache_ttl_post):
107
+ return cached
108
+
109
+ try:
110
+ html = await fetch_html(client, settings.bearblog_url, slug)
111
+ except NotFoundError as e:
112
+ raise NotFound(str(e)) from e
113
+ except ServerError as e:
114
+ raise TemporaryFailure(str(e)) from e
115
+
116
+ title, date = extract_metadata(html)
117
+ content = html_to_gemtext(html)
118
+
119
+ template = templates.get_template("post.gmi")
120
+ gemtext = template.render(
121
+ title=title,
122
+ date=date if include_date else None,
123
+ content=content,
124
+ web_url=f"{settings.bearblog_url}/{slug}/",
125
+ )
126
+
127
+ cache.set(cache_key, gemtext)
128
+ return gemtext
129
+
130
+ @app.gemini("/")
131
+ async def index(request: Request) -> str:
132
+ """Landing page with recent posts and page links."""
133
+ feed = await _get_feed(request.app.state.client)
134
+
135
+ posts = []
136
+ for entry in feed.entries[:10]:
137
+ link = getattr(entry, "link", None)
138
+ if not link:
139
+ continue
140
+ slug = extract_slug(link)
141
+ if not slug:
142
+ continue
143
+ date = entry.get("published", "")[:16] if entry.get("published") else ""
144
+ title = getattr(entry, "title", "Untitled")
145
+ posts.append({"slug": slug, "title": title, "date": date})
146
+
147
+ template = templates.get_template("index.gmi")
148
+ return template.render(
149
+ blog_name=settings.blog_name,
150
+ description=feed.feed.get("description", ""),
151
+ pages=settings.pages,
152
+ posts=posts,
153
+ )
154
+
155
+ @app.gemini("/post/{slug}")
156
+ async def post(request: Request, slug: str) -> str:
157
+ """Individual blog post."""
158
+ return await _render_content(
159
+ request.app.state.client, slug, "post", include_date=True
160
+ )
161
+
162
+ @app.gemini("/page/{slug}")
163
+ async def page(request: Request, slug: str) -> str:
164
+ """Static page (projects, now, etc.)."""
165
+ return await _render_content(
166
+ request.app.state.client, slug, "page", include_date=False
167
+ )
168
+
169
+ @app.gemini("/about")
170
+ async def about(request: Request) -> str:
171
+ """About page from feed metadata."""
172
+ feed = await _get_feed(request.app.state.client)
173
+ description = feed.feed.get("description", "A personal blog.")
174
+
175
+ template = templates.get_template("about.gmi")
176
+ return template.render(
177
+ blog_name=settings.blog_name,
178
+ description=description,
179
+ bearblog_url=settings.bearblog_url,
180
+ )
181
+
182
+ @app.gemini("/feed")
183
+ async def feed_route(request: Request) -> Response:
184
+ """Atom feed with Gemini URLs."""
185
+ rss = await _get_feed(request.app.state.client)
186
+
187
+ # Use configured gemini_host or fall back to request hostname
188
+ host = settings.gemini_host or request.hostname or "localhost"
189
+ base_url = f"gemini://{host}"
190
+
191
+ # Get the most recent update time
192
+ updated = _rfc822_to_iso(rss.feed.get("updated", ""))
193
+
194
+ entries = []
195
+ for entry in rss.entries:
196
+ link = getattr(entry, "link", None)
197
+ if not link:
198
+ continue
199
+ slug = extract_slug(link)
200
+ if not slug:
201
+ continue
202
+
203
+ entries.append(
204
+ {
205
+ "title": getattr(entry, "title", "Untitled"),
206
+ "url": f"{base_url}/post/{slug}",
207
+ "published": _rfc822_to_iso(entry.get("published", "")),
208
+ "summary": getattr(entry, "description", ""),
209
+ }
210
+ )
211
+
212
+ template = xml_templates.get_template("feed.xml")
213
+ atom_xml = template.render(
214
+ blog_name=settings.blog_name,
215
+ base_url=base_url,
216
+ updated=updated,
217
+ entries=entries,
218
+ )
219
+
220
+ return Response(body=atom_xml, mime_type="application/atom+xml")
221
+
222
+ return app
223
+
224
+
225
+ def main() -> None:
226
+ """Entry point for standalone execution."""
227
+ settings = load_settings()
228
+ app = create_app(settings)
229
+ app.run(
230
+ host=settings.host,
231
+ port=settings.port,
232
+ certfile=settings.cert_file,
233
+ keyfile=settings.key_file,
234
+ )
@@ -74,8 +74,12 @@ class Settings(BaseSettings):
74
74
  return v
75
75
 
76
76
 
77
- def _load_settings() -> Settings:
78
- """Load settings, handling missing TOML file gracefully."""
77
+ def load_settings() -> Settings:
78
+ """Load settings from TOML file and/or environment variables.
79
+
80
+ Use this when running ursaproxy standalone. For embedding in other apps,
81
+ create a Settings instance directly with the desired configuration.
82
+ """
79
83
  try:
80
84
  return Settings() # type: ignore[call-arg]
81
85
  except FileNotFoundError:
@@ -93,6 +97,3 @@ def _load_settings() -> Settings:
93
97
  return (env_settings,)
94
98
 
95
99
  return _EnvOnlySettings() # type: ignore[call-arg]
96
-
97
-
98
- settings = _load_settings()
@@ -1,8 +1,6 @@
1
1
  import feedparser
2
2
  import httpx
3
3
 
4
- from .config import settings
5
-
6
4
 
7
5
  class FetchError(Exception):
8
6
  """Base error for fetch operations."""
@@ -35,19 +33,30 @@ async def _fetch(
35
33
  raise ServerError(f"Network error: {e}") from e
36
34
 
37
35
 
38
- async def fetch_feed(client: httpx.AsyncClient) -> feedparser.FeedParserDict:
39
- """Fetch RSS feed from Bearblog."""
40
- url = f"{settings.bearblog_url}/feed/?type=rss"
36
+ async def fetch_feed(
37
+ client: httpx.AsyncClient, bearblog_url: str
38
+ ) -> feedparser.FeedParserDict:
39
+ """Fetch RSS feed from Bearblog.
40
+
41
+ Args:
42
+ client: HTTP client instance.
43
+ bearblog_url: Base URL of the Bearblog site.
44
+ """
45
+ url = f"{bearblog_url}/feed/?type=rss"
41
46
  response = await _fetch(client, url, f"Feed not found at {url}")
42
47
  return feedparser.parse(response.text)
43
48
 
44
49
 
45
- async def fetch_html(client: httpx.AsyncClient, slug: str) -> str:
46
- """
47
- Fetch HTML page from Bearblog.
50
+ async def fetch_html(client: httpx.AsyncClient, bearblog_url: str, slug: str) -> str:
51
+ """Fetch HTML page from Bearblog.
52
+
53
+ Args:
54
+ client: HTTP client instance.
55
+ bearblog_url: Base URL of the Bearblog site.
56
+ slug: Page slug (without leading/trailing slashes).
48
57
 
49
58
  Note: Bearblog URLs have trailing slashes: /{slug}/
50
59
  """
51
- url = f"{settings.bearblog_url}/{slug}/"
60
+ url = f"{bearblog_url}/{slug}/"
52
61
  response = await _fetch(client, url, f"Page not found: {slug}")
53
62
  return response.text
@@ -1,203 +0,0 @@
1
- from datetime import datetime
2
- from email.utils import parsedate_to_datetime
3
-
4
- import httpx
5
- from jinja2 import Environment, PackageLoader
6
- from xitzin import NotFound, Request, Response, TemporaryFailure, Xitzin
7
-
8
- from .cache import cache
9
- from .config import settings
10
- from .converter import extract_metadata, extract_slug, html_to_gemtext
11
- from .fetcher import NotFoundError, ServerError, fetch_feed, fetch_html
12
-
13
- app = Xitzin()
14
-
15
- # Template environments
16
- templates = Environment(
17
- loader=PackageLoader("ursaproxy", "templates"),
18
- autoescape=False, # Gemtext doesn't need HTML escaping
19
- )
20
-
21
- xml_templates = Environment(
22
- loader=PackageLoader("ursaproxy", "templates"),
23
- autoescape=True, # XML escaping for feed
24
- )
25
-
26
-
27
- @app.on_startup
28
- async def startup() -> None:
29
- """Initialize shared HTTP client."""
30
- app.state.client = httpx.AsyncClient(timeout=30.0)
31
-
32
-
33
- @app.on_shutdown
34
- async def shutdown() -> None:
35
- """Close HTTP client."""
36
- await app.state.client.aclose()
37
-
38
-
39
- async def _get_feed(client: httpx.AsyncClient):
40
- """Fetch feed with caching and error handling."""
41
- if cached := cache.get("feed", settings.cache_ttl_feed):
42
- return cached
43
-
44
- try:
45
- feed = await fetch_feed(client)
46
- cache.set("feed", feed)
47
- return feed
48
- except ServerError as e:
49
- raise TemporaryFailure(str(e)) from e
50
- except NotFoundError as e:
51
- raise NotFound(str(e)) from e
52
-
53
-
54
- async def _render_content(
55
- client: httpx.AsyncClient,
56
- slug: str,
57
- content_type: str,
58
- include_date: bool = True,
59
- ) -> str:
60
- """Fetch and render content as gemtext with caching."""
61
- cache_key = f"{content_type}:{slug}"
62
-
63
- if cached := cache.get(cache_key, settings.cache_ttl_post):
64
- return cached
65
-
66
- try:
67
- html = await fetch_html(client, slug)
68
- except NotFoundError as e:
69
- raise NotFound(str(e)) from e
70
- except ServerError as e:
71
- raise TemporaryFailure(str(e)) from e
72
-
73
- title, date = extract_metadata(html)
74
- content = html_to_gemtext(html)
75
-
76
- template = templates.get_template("post.gmi")
77
- gemtext = template.render(
78
- title=title,
79
- date=date if include_date else None,
80
- content=content,
81
- web_url=f"{settings.bearblog_url}/{slug}/",
82
- )
83
-
84
- cache.set(cache_key, gemtext)
85
- return gemtext
86
-
87
-
88
- @app.gemini("/")
89
- async def index(request: Request) -> str:
90
- """Landing page with recent posts and page links."""
91
- feed = await _get_feed(request.app.state.client)
92
-
93
- posts = []
94
- for entry in feed.entries[:10]:
95
- link = getattr(entry, "link", None)
96
- if not link:
97
- continue
98
- slug = extract_slug(link)
99
- if not slug:
100
- continue
101
- date = entry.get("published", "")[:16] if entry.get("published") else ""
102
- title = getattr(entry, "title", "Untitled")
103
- posts.append({"slug": slug, "title": title, "date": date})
104
-
105
- template = templates.get_template("index.gmi")
106
- return template.render(
107
- blog_name=settings.blog_name,
108
- description=feed.feed.get("description", ""),
109
- pages=settings.pages,
110
- posts=posts,
111
- )
112
-
113
-
114
- @app.gemini("/post/{slug}")
115
- async def post(request: Request, slug: str) -> str:
116
- """Individual blog post."""
117
- return await _render_content(
118
- request.app.state.client, slug, "post", include_date=True
119
- )
120
-
121
-
122
- @app.gemini("/page/{slug}")
123
- async def page(request: Request, slug: str) -> str:
124
- """Static page (projects, now, etc.)."""
125
- return await _render_content(
126
- request.app.state.client, slug, "page", include_date=False
127
- )
128
-
129
-
130
- @app.gemini("/about")
131
- async def about(request: Request) -> str:
132
- """About page from feed metadata."""
133
- feed = await _get_feed(request.app.state.client)
134
- description = feed.feed.get("description", "A personal blog.")
135
-
136
- template = templates.get_template("about.gmi")
137
- return template.render(
138
- blog_name=settings.blog_name,
139
- description=description,
140
- bearblog_url=settings.bearblog_url,
141
- )
142
-
143
-
144
- def _rfc822_to_iso(date_str: str) -> str:
145
- """Convert RFC 822 date to ISO 8601 format for Atom feeds."""
146
- if not date_str:
147
- return datetime.now().isoformat() + "Z"
148
- try:
149
- dt = parsedate_to_datetime(date_str)
150
- return dt.isoformat().replace("+00:00", "Z")
151
- except (ValueError, TypeError):
152
- return datetime.now().isoformat() + "Z"
153
-
154
-
155
- @app.gemini("/feed")
156
- async def feed(request: Request) -> Response:
157
- """Atom feed with Gemini URLs."""
158
- rss = await _get_feed(request.app.state.client)
159
-
160
- # Use configured gemini_host or fall back to request hostname
161
- host = settings.gemini_host or request.hostname or "localhost"
162
- base_url = f"gemini://{host}"
163
-
164
- # Get the most recent update time
165
- updated = _rfc822_to_iso(rss.feed.get("updated", ""))
166
-
167
- entries = []
168
- for entry in rss.entries:
169
- link = getattr(entry, "link", None)
170
- if not link:
171
- continue
172
- slug = extract_slug(link)
173
- if not slug:
174
- continue
175
-
176
- entries.append(
177
- {
178
- "title": getattr(entry, "title", "Untitled"),
179
- "url": f"{base_url}/post/{slug}",
180
- "published": _rfc822_to_iso(entry.get("published", "")),
181
- "summary": getattr(entry, "description", ""),
182
- }
183
- )
184
-
185
- template = xml_templates.get_template("feed.xml")
186
- atom_xml = template.render(
187
- blog_name=settings.blog_name,
188
- base_url=base_url,
189
- updated=updated,
190
- entries=entries,
191
- )
192
-
193
- return Response(body=atom_xml, mime_type="application/atom+xml")
194
-
195
-
196
- def main() -> None:
197
- """Entry point."""
198
- app.run(
199
- host=settings.host,
200
- port=settings.port,
201
- certfile=settings.cert_file,
202
- keyfile=settings.key_file,
203
- )