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.
- skrift/admin/controller.py +114 -26
- skrift/alembic/versions/20260202_add_content_scheduling.py +35 -0
- skrift/alembic/versions/20260202_add_page_ordering.py +32 -0
- skrift/alembic/versions/20260202_add_page_revisions.py +46 -0
- skrift/alembic/versions/20260202_add_seo_fields.py +42 -0
- skrift/claude_skill/SKILL.md +205 -0
- skrift/claude_skill/__init__.py +0 -0
- skrift/claude_skill/architecture.md +328 -0
- skrift/claude_skill/patterns.md +552 -0
- skrift/cli.py +47 -0
- skrift/controllers/sitemap.py +116 -0
- skrift/controllers/web.py +18 -1
- skrift/db/models/__init__.py +2 -1
- skrift/db/models/page.py +26 -1
- skrift/db/models/page_revision.py +45 -0
- skrift/db/services/page_service.py +113 -6
- skrift/db/services/revision_service.py +146 -0
- skrift/db/services/setting_service.py +7 -0
- skrift/lib/__init__.py +12 -1
- skrift/lib/flash.py +103 -0
- skrift/lib/hooks.py +292 -0
- skrift/lib/seo.py +103 -0
- skrift/static/css/style.css +92 -2
- skrift/templates/admin/pages/edit.html +44 -4
- skrift/templates/admin/pages/list.html +8 -1
- skrift/templates/admin/pages/revisions.html +59 -0
- skrift/templates/base.html +58 -4
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/METADATA +14 -3
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/RECORD +31 -16
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/WHEEL +0 -0
- {skrift-0.1.0a14.dist-info → skrift-0.1.0a16.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
+
)
|