skrift 0.1.0a14__py3-none-any.whl → 0.1.0a16__py3-none-any.whl

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.
@@ -0,0 +1,552 @@
1
+ # Skrift Code Patterns
2
+
3
+ ## Controller Patterns
4
+
5
+ ### Basic Controller
6
+
7
+ ```python
8
+ from pathlib import Path
9
+ from litestar import Controller, get, post
10
+ from litestar.response import Template as TemplateResponse
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
14
+
15
+ class ItemController(Controller):
16
+ path = "/items"
17
+
18
+ @get("/")
19
+ async def list_items(self, db_session: AsyncSession) -> TemplateResponse:
20
+ items = await item_service.list_items(db_session)
21
+ return TemplateResponse("items/list.html", context={"items": items})
22
+
23
+ @get("/{item_id:uuid}")
24
+ async def get_item(
25
+ self, db_session: AsyncSession, item_id: UUID
26
+ ) -> TemplateResponse:
27
+ item = await item_service.get_by_id(db_session, item_id)
28
+ if not item:
29
+ raise NotFoundException(f"Item {item_id} not found")
30
+ return TemplateResponse("items/detail.html", context={"item": item})
31
+ ```
32
+
33
+ ### Protected Controller with Guards
34
+
35
+ ```python
36
+ from skrift.auth import auth_guard, Permission, Role
37
+
38
+ class AdminController(Controller):
39
+ path = "/admin"
40
+ guards = [auth_guard, Role("admin")]
41
+
42
+ @get("/")
43
+ async def dashboard(self) -> TemplateResponse:
44
+ return TemplateResponse("admin/dashboard.html")
45
+
46
+ @get("/users")
47
+ async def list_users(self, db_session: AsyncSession) -> TemplateResponse:
48
+ # Additional permission check beyond role
49
+ ...
50
+ ```
51
+
52
+ ### Controller with Request/Session Access
53
+
54
+ ```python
55
+ from litestar import Request
56
+
57
+ class AuthController(Controller):
58
+ path = "/auth"
59
+
60
+ @get("/profile")
61
+ async def profile(
62
+ self, request: Request, db_session: AsyncSession
63
+ ) -> TemplateResponse:
64
+ user_id = request.session.get("user_id")
65
+ if not user_id:
66
+ raise NotAuthorizedException()
67
+
68
+ user = await user_service.get_by_id(db_session, UUID(user_id))
69
+ return TemplateResponse("auth/profile.html", context={"user": user})
70
+
71
+ @post("/logout")
72
+ async def logout(self, request: Request) -> Redirect:
73
+ request.session.clear()
74
+ return Redirect("/")
75
+ ```
76
+
77
+ ### Controller with Flash Messages
78
+
79
+ ```python
80
+ @post("/items")
81
+ async def create_item(
82
+ self, request: Request, db_session: AsyncSession, data: ItemCreate
83
+ ) -> Redirect:
84
+ await item_service.create(db_session, data)
85
+ request.session["flash"] = {"type": "success", "message": "Item created!"}
86
+ return Redirect("/items")
87
+
88
+ @get("/items")
89
+ async def list_items(self, request: Request, db_session: AsyncSession):
90
+ flash = request.session.pop("flash", None) # Get and remove
91
+ items = await item_service.list_items(db_session)
92
+ return TemplateResponse("items/list.html", context={"items": items, "flash": flash})
93
+ ```
94
+
95
+ ## Database Model Patterns
96
+
97
+ ### Basic Model
98
+
99
+ ```python
100
+ from sqlalchemy import String, Text, Boolean
101
+ from sqlalchemy.orm import Mapped, mapped_column
102
+ from skrift.db.base import Base
103
+
104
+ class Article(Base):
105
+ __tablename__ = "articles"
106
+
107
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
108
+ content: Mapped[str] = mapped_column(Text, nullable=False, default="")
109
+ is_published: Mapped[bool] = mapped_column(Boolean, default=False)
110
+ ```
111
+
112
+ ### Model with Relationships
113
+
114
+ ```python
115
+ from sqlalchemy import ForeignKey
116
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
117
+ from uuid import UUID
118
+
119
+ class Comment(Base):
120
+ __tablename__ = "comments"
121
+
122
+ # Foreign key
123
+ article_id: Mapped[UUID] = mapped_column(
124
+ ForeignKey("articles.id", ondelete="CASCADE"),
125
+ nullable=False,
126
+ index=True,
127
+ )
128
+
129
+ # Relationship
130
+ article: Mapped["Article"] = relationship("Article", back_populates="comments")
131
+
132
+ content: Mapped[str] = mapped_column(Text, nullable=False)
133
+
134
+ # In Article model:
135
+ class Article(Base):
136
+ # ...
137
+ comments: Mapped[list["Comment"]] = relationship(
138
+ "Comment",
139
+ back_populates="article",
140
+ cascade="all, delete-orphan",
141
+ order_by="desc(Comment.created_at)",
142
+ )
143
+ ```
144
+
145
+ ### Model with Optional Fields
146
+
147
+ ```python
148
+ from datetime import datetime
149
+
150
+ class Event(Base):
151
+ __tablename__ = "events"
152
+
153
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
154
+ # Optional fields use | None
155
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
156
+ starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
157
+ ```
158
+
159
+ ### Model with Indexes
160
+
161
+ ```python
162
+ class LogEntry(Base):
163
+ __tablename__ = "log_entries"
164
+
165
+ level: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
166
+ message: Mapped[str] = mapped_column(Text, nullable=False)
167
+ # Composite index
168
+ __table_args__ = (
169
+ Index("ix_log_entries_level_created", "level", "created_at"),
170
+ )
171
+ ```
172
+
173
+ ## Service Layer Patterns
174
+
175
+ ### Basic CRUD Service
176
+
177
+ ```python
178
+ from sqlalchemy import select
179
+ from sqlalchemy.ext.asyncio import AsyncSession
180
+ from uuid import UUID
181
+
182
+ async def list_items(
183
+ db_session: AsyncSession,
184
+ limit: int | None = None,
185
+ offset: int = 0,
186
+ ) -> list[Item]:
187
+ query = select(Item).order_by(Item.created_at.desc())
188
+ if offset:
189
+ query = query.offset(offset)
190
+ if limit:
191
+ query = query.limit(limit)
192
+ result = await db_session.execute(query)
193
+ return list(result.scalars().all())
194
+
195
+ async def get_by_id(db_session: AsyncSession, item_id: UUID) -> Item | None:
196
+ result = await db_session.execute(select(Item).where(Item.id == item_id))
197
+ return result.scalar_one_or_none()
198
+
199
+ async def create(db_session: AsyncSession, name: str, **kwargs) -> Item:
200
+ item = Item(name=name, **kwargs)
201
+ db_session.add(item)
202
+ await db_session.commit()
203
+ await db_session.refresh(item)
204
+ return item
205
+
206
+ async def update(
207
+ db_session: AsyncSession,
208
+ item_id: UUID,
209
+ **updates,
210
+ ) -> Item | None:
211
+ item = await get_by_id(db_session, item_id)
212
+ if not item:
213
+ return None
214
+
215
+ for key, value in updates.items():
216
+ if value is not None:
217
+ setattr(item, key, value)
218
+
219
+ await db_session.commit()
220
+ await db_session.refresh(item)
221
+ return item
222
+
223
+ async def delete(db_session: AsyncSession, item_id: UUID) -> bool:
224
+ item = await get_by_id(db_session, item_id)
225
+ if not item:
226
+ return False
227
+ await db_session.delete(item)
228
+ await db_session.commit()
229
+ return True
230
+ ```
231
+
232
+ ### Service with Filtering
233
+
234
+ ```python
235
+ from sqlalchemy import select, and_, or_
236
+ from datetime import datetime, UTC
237
+
238
+ async def list_published_articles(
239
+ db_session: AsyncSession,
240
+ category: str | None = None,
241
+ search: str | None = None,
242
+ ) -> list[Article]:
243
+ query = select(Article).where(Article.is_published == True)
244
+
245
+ filters = []
246
+ if category:
247
+ filters.append(Article.category == category)
248
+ if search:
249
+ filters.append(
250
+ or_(
251
+ Article.title.ilike(f"%{search}%"),
252
+ Article.content.ilike(f"%{search}%"),
253
+ )
254
+ )
255
+
256
+ if filters:
257
+ query = query.where(and_(*filters))
258
+
259
+ query = query.order_by(Article.published_at.desc())
260
+ result = await db_session.execute(query)
261
+ return list(result.scalars().all())
262
+ ```
263
+
264
+ ### Service with Hooks
265
+
266
+ ```python
267
+ from skrift.lib.hooks import hooks
268
+
269
+ BEFORE_ITEM_SAVE = "before_item_save"
270
+ AFTER_ITEM_SAVE = "after_item_save"
271
+
272
+ async def create(db_session: AsyncSession, name: str) -> Item:
273
+ item = Item(name=name)
274
+
275
+ await hooks.do_action(BEFORE_ITEM_SAVE, item, is_new=True)
276
+
277
+ db_session.add(item)
278
+ await db_session.commit()
279
+ await db_session.refresh(item)
280
+
281
+ await hooks.do_action(AFTER_ITEM_SAVE, item, is_new=True)
282
+
283
+ return item
284
+ ```
285
+
286
+ ## Hook/Filter Patterns
287
+
288
+ ### Action Hook
289
+
290
+ ```python
291
+ from skrift.lib.hooks import action
292
+
293
+ @action("after_page_save", priority=10)
294
+ async def invalidate_cache(page, is_new: bool):
295
+ """Clear cache when page is saved."""
296
+ cache.delete(f"page:{page.slug}")
297
+
298
+ @action("after_user_register", priority=5)
299
+ async def send_welcome_email(user):
300
+ """Send welcome email to new users."""
301
+ await email_service.send_welcome(user.email)
302
+ ```
303
+
304
+ ### Filter Hook
305
+
306
+ ```python
307
+ from skrift.lib.hooks import filter
308
+
309
+ @filter("page_seo_meta", priority=10)
310
+ async def add_default_author(meta: dict, page) -> dict:
311
+ """Add default author to SEO meta."""
312
+ if "author" not in meta:
313
+ meta["author"] = "Site Author"
314
+ return meta
315
+
316
+ @filter("template_context", priority=20)
317
+ async def add_global_vars(context: dict) -> dict:
318
+ """Add global variables to all templates."""
319
+ context["current_year"] = datetime.now().year
320
+ context["version"] = "1.0.0"
321
+ return context
322
+ ```
323
+
324
+ ### Custom Hook Points
325
+
326
+ ```python
327
+ # Define hook constants
328
+ MY_BEFORE_SAVE = "my_before_save"
329
+ MY_AFTER_SAVE = "my_after_save"
330
+ MY_DATA_FILTER = "my_data_filter"
331
+
332
+ # Trigger in service
333
+ async def save_thing(db_session: AsyncSession, data: dict) -> Thing:
334
+ # Apply filters to data
335
+ data = await hooks.apply_filters(MY_DATA_FILTER, data)
336
+
337
+ thing = Thing(**data)
338
+ await hooks.do_action(MY_BEFORE_SAVE, thing)
339
+
340
+ db_session.add(thing)
341
+ await db_session.commit()
342
+
343
+ await hooks.do_action(MY_AFTER_SAVE, thing)
344
+ return thing
345
+ ```
346
+
347
+ ## Template Patterns
348
+
349
+ ### Using Template Class
350
+
351
+ ```python
352
+ from skrift.lib.template import Template
353
+ from pathlib import Path
354
+
355
+ TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
356
+
357
+ @get("/{slug:str}")
358
+ async def view_item(self, db_session: AsyncSession, slug: str) -> TemplateResponse:
359
+ item = await item_service.get_by_slug(db_session, slug)
360
+
361
+ # Tries: item-{slug}.html -> item.html
362
+ template = Template("item", slug, context={"item": item})
363
+ return template.render(TEMPLATE_DIR)
364
+ ```
365
+
366
+ ### Template with SEO Context
367
+
368
+ ```python
369
+ from skrift.lib.seo import get_page_seo_meta, get_page_og_meta
370
+
371
+ @get("/{slug:path}")
372
+ async def view_page(self, request: Request, db_session: AsyncSession, slug: str):
373
+ page = await page_service.get_by_slug(db_session, slug)
374
+
375
+ site_name = get_cached_site_name()
376
+ base_url = str(request.base_url).rstrip("/")
377
+
378
+ seo_meta = await get_page_seo_meta(page, site_name, base_url)
379
+ og_meta = await get_page_og_meta(page, site_name, base_url)
380
+
381
+ return TemplateResponse("page.html", context={
382
+ "page": page,
383
+ "seo_meta": seo_meta,
384
+ "og_meta": og_meta,
385
+ })
386
+ ```
387
+
388
+ ## Middleware Patterns
389
+
390
+ ### Simple Middleware
391
+
392
+ ```python
393
+ # myapp/middleware.py
394
+ from litestar.middleware import AbstractMiddleware
395
+ from litestar.types import ASGIApp, Receive, Scope, Send
396
+
397
+ class LoggingMiddleware(AbstractMiddleware):
398
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
399
+ if scope["type"] == "http":
400
+ print(f"Request: {scope['method']} {scope['path']}")
401
+ await self.app(scope, receive, send)
402
+
403
+ def create_logging_middleware(app: ASGIApp) -> ASGIApp:
404
+ return LoggingMiddleware(app=app)
405
+ ```
406
+
407
+ Register in app.yaml:
408
+ ```yaml
409
+ middleware:
410
+ - myapp.middleware:create_logging_middleware
411
+ ```
412
+
413
+ ### Middleware with Configuration
414
+
415
+ ```python
416
+ from litestar.middleware import DefineMiddleware
417
+
418
+ class RateLimitMiddleware(AbstractMiddleware):
419
+ def __init__(self, app: ASGIApp, requests_per_minute: int = 60):
420
+ super().__init__(app)
421
+ self.limit = requests_per_minute
422
+
423
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
424
+ # Rate limiting logic...
425
+ await self.app(scope, receive, send)
426
+
427
+ def create_rate_limit_middleware(app: ASGIApp, requests_per_minute: int = 60) -> ASGIApp:
428
+ return RateLimitMiddleware(app, requests_per_minute)
429
+ ```
430
+
431
+ Register with kwargs:
432
+ ```yaml
433
+ middleware:
434
+ - factory: myapp.middleware:create_rate_limit_middleware
435
+ kwargs:
436
+ requests_per_minute: 100
437
+ ```
438
+
439
+ ## Authorization Patterns
440
+
441
+ ### Permission-Based Access
442
+
443
+ ```python
444
+ from skrift.auth import auth_guard, Permission
445
+
446
+ class ArticleController(Controller):
447
+ path = "/articles"
448
+ guards = [auth_guard] # All routes require auth
449
+
450
+ @get("/")
451
+ async def list_articles(self, db_session: AsyncSession):
452
+ # Anyone authenticated can list
453
+ ...
454
+
455
+ @post("/", guards=[Permission("create-articles")])
456
+ async def create_article(self, db_session: AsyncSession, data: ArticleCreate):
457
+ # Only users with create-articles permission
458
+ ...
459
+
460
+ @delete("/{id:uuid}", guards=[Permission("delete-articles")])
461
+ async def delete_article(self, db_session: AsyncSession, id: UUID):
462
+ # Only users with delete-articles permission
463
+ ...
464
+ ```
465
+
466
+ ### Custom Role Registration
467
+
468
+ ```python
469
+ # In your app's __init__.py or startup module
470
+ from skrift.auth import register_role
471
+
472
+ # Register before database sync (app startup)
473
+ register_role(
474
+ "contributor",
475
+ "create-articles",
476
+ "edit-own-articles",
477
+ display_name="Contributor",
478
+ description="Can create and edit their own articles",
479
+ )
480
+
481
+ register_role(
482
+ "reviewer",
483
+ "view-drafts",
484
+ "approve-articles",
485
+ display_name="Reviewer",
486
+ description="Can review and approve articles",
487
+ )
488
+ ```
489
+
490
+ ## Testing Patterns
491
+
492
+ ### Controller Test
493
+
494
+ ```python
495
+ import pytest
496
+ from litestar.testing import TestClient
497
+
498
+ @pytest.fixture
499
+ def client(app):
500
+ return TestClient(app)
501
+
502
+ async def test_list_items(client, db_session):
503
+ # Create test data
504
+ item = await item_service.create(db_session, name="Test")
505
+
506
+ response = client.get("/items")
507
+ assert response.status_code == 200
508
+ assert "Test" in response.text
509
+ ```
510
+
511
+ ### Service Test
512
+
513
+ ```python
514
+ import pytest
515
+
516
+ async def test_create_item(db_session):
517
+ item = await item_service.create(db_session, name="Test Item")
518
+
519
+ assert item.id is not None
520
+ assert item.name == "Test Item"
521
+
522
+ async def test_list_items_filters(db_session):
523
+ await item_service.create(db_session, name="Item A", published=True)
524
+ await item_service.create(db_session, name="Item B", published=False)
525
+
526
+ published = await item_service.list_items(db_session, published_only=True)
527
+
528
+ assert len(published) == 1
529
+ assert published[0].name == "Item A"
530
+ ```
531
+
532
+ ### Hook Test
533
+
534
+ ```python
535
+ from skrift.lib.hooks import hooks
536
+
537
+ async def test_hook_called(db_session):
538
+ called_with = []
539
+
540
+ async def track_save(page, is_new):
541
+ called_with.append((page.title, is_new))
542
+
543
+ hooks.add_action("after_page_save", track_save)
544
+
545
+ try:
546
+ await page_service.create(db_session, slug="test", title="Test")
547
+
548
+ assert len(called_with) == 1
549
+ assert called_with[0] == ("Test", True)
550
+ finally:
551
+ hooks.remove_action("after_page_save", track_save)
552
+ ```
skrift/cli.py CHANGED
@@ -139,5 +139,52 @@ def db(ctx):
139
139
  sys.exit(alembic_main(alembic_argv))
140
140
 
141
141
 
142
+ @cli.command("init-claude")
143
+ @click.option(
144
+ "--force",
145
+ is_flag=True,
146
+ help="Overwrite existing skill files",
147
+ )
148
+ def init_claude(force):
149
+ """Set up Claude Code skill for Skrift development.
150
+
151
+ Copies the Skrift skill files to .claude/skills/skrift/ in the current
152
+ directory, enabling Claude Code to understand Skrift conventions.
153
+
154
+ \b
155
+ Creates:
156
+ .claude/skills/skrift/SKILL.md - Main skill with dynamic context
157
+ .claude/skills/skrift/architecture.md - System architecture docs
158
+ .claude/skills/skrift/patterns.md - Code patterns and examples
159
+ """
160
+ import importlib.resources
161
+
162
+ skill_dir = Path.cwd() / ".claude" / "skills" / "skrift"
163
+
164
+ # Check if skill already exists
165
+ if skill_dir.exists() and not force:
166
+ click.echo(f"Skill directory already exists: {skill_dir}", err=True)
167
+ click.echo("Use --force to overwrite existing files.", err=True)
168
+ sys.exit(1)
169
+
170
+ # Create directory
171
+ skill_dir.mkdir(parents=True, exist_ok=True)
172
+
173
+ # Copy skill files from package
174
+ skill_files = ["SKILL.md", "architecture.md", "patterns.md"]
175
+ package_files = importlib.resources.files("skrift.claude_skill")
176
+
177
+ for filename in skill_files:
178
+ source = package_files.joinpath(filename)
179
+ dest = skill_dir / filename
180
+
181
+ content = source.read_text()
182
+ dest.write_text(content)
183
+ click.echo(f"Created {dest.relative_to(Path.cwd())}")
184
+
185
+ click.echo()
186
+ click.echo("Claude Code skill installed. Use /skrift to activate.")
187
+
188
+
142
189
  if __name__ == "__main__":
143
190
  cli()
@@ -0,0 +1,116 @@
1
+ """Sitemap and robots.txt controller for SEO."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from xml.etree.ElementTree import Element, SubElement, tostring
6
+
7
+ from litestar import Controller, Request, get
8
+ from litestar.response import Response
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from skrift.db.services import page_service
12
+ from skrift.db.services.setting_service import get_cached_site_base_url
13
+ from skrift.lib.hooks import hooks, SITEMAP_PAGE, SITEMAP_URLS, ROBOTS_TXT
14
+
15
+
16
+ @dataclass
17
+ class SitemapEntry:
18
+ """A single entry in the sitemap."""
19
+
20
+ loc: str
21
+ lastmod: datetime | None = None
22
+ changefreq: str | None = None
23
+ priority: float | None = None
24
+
25
+
26
+ class SitemapController(Controller):
27
+ """Controller for sitemap.xml and robots.txt."""
28
+
29
+ path = "/"
30
+
31
+ @get("/sitemap.xml")
32
+ async def sitemap(
33
+ self, request: Request, db_session: AsyncSession
34
+ ) -> Response:
35
+ """Generate sitemap.xml with published pages."""
36
+ base_url = get_cached_site_base_url() or str(request.base_url).rstrip("/")
37
+
38
+ # Get all published pages (respects scheduling)
39
+ pages = await page_service.list_pages(db_session, published_only=True)
40
+
41
+ entries: list[SitemapEntry] = []
42
+
43
+ for page in pages:
44
+ slug = page.slug.strip("/")
45
+ loc = f"{base_url}/{slug}" if slug else base_url
46
+
47
+ entry = SitemapEntry(
48
+ loc=loc,
49
+ lastmod=page.updated_at or page.created_at,
50
+ changefreq="weekly",
51
+ priority=0.8 if slug else 1.0, # Home page gets higher priority
52
+ )
53
+
54
+ # Apply sitemap_page filter (can return None to exclude)
55
+ entry = await hooks.apply_filters(SITEMAP_PAGE, entry, page)
56
+ if entry is not None:
57
+ entries.append(entry)
58
+
59
+ # Apply sitemap_urls filter to allow adding custom entries
60
+ entries = await hooks.apply_filters(SITEMAP_URLS, entries)
61
+
62
+ # Build XML
63
+ xml = self._build_sitemap_xml(entries)
64
+
65
+ return Response(
66
+ content=xml,
67
+ media_type="application/xml",
68
+ headers={"Content-Type": "application/xml; charset=utf-8"},
69
+ )
70
+
71
+ def _build_sitemap_xml(self, entries: list[SitemapEntry]) -> bytes:
72
+ """Build sitemap XML from entries."""
73
+ urlset = Element("urlset")
74
+ urlset.set("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")
75
+
76
+ for entry in entries:
77
+ url = SubElement(urlset, "url")
78
+ loc = SubElement(url, "loc")
79
+ loc.text = entry.loc
80
+
81
+ if entry.lastmod:
82
+ lastmod = SubElement(url, "lastmod")
83
+ lastmod.text = entry.lastmod.strftime("%Y-%m-%d")
84
+
85
+ if entry.changefreq:
86
+ changefreq = SubElement(url, "changefreq")
87
+ changefreq.text = entry.changefreq
88
+
89
+ if entry.priority is not None:
90
+ priority = SubElement(url, "priority")
91
+ priority.text = str(entry.priority)
92
+
93
+ return b'<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(urlset, encoding="utf-8")
94
+
95
+ @get("/robots.txt")
96
+ async def robots(
97
+ self, request: Request, db_session: AsyncSession
98
+ ) -> Response:
99
+ """Generate robots.txt with sitemap reference."""
100
+ base_url = get_cached_site_base_url() or str(request.base_url).rstrip("/")
101
+ sitemap_url = f"{base_url}/sitemap.xml"
102
+
103
+ content = f"""User-agent: *
104
+ Allow: /
105
+
106
+ Sitemap: {sitemap_url}
107
+ """
108
+
109
+ # Apply robots_txt filter for customization
110
+ content = await hooks.apply_filters(ROBOTS_TXT, content)
111
+
112
+ return Response(
113
+ content=content,
114
+ media_type="text/plain",
115
+ headers={"Content-Type": "text/plain; charset=utf-8"},
116
+ )