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.
@@ -26,8 +26,9 @@ from skrift.auth.roles import ROLE_DEFINITIONS
26
26
  from skrift.admin.navigation import build_admin_nav, ADMIN_NAV_TAG
27
27
  from skrift.db.models.user import User
28
28
  from skrift.db.models import Page
29
- from skrift.db.services import page_service
29
+ from skrift.db.services import page_service, revision_service
30
30
  from skrift.db.services import setting_service
31
+ from skrift.lib.flash import flash_success, flash_error, flash_info, get_flash_messages
31
32
 
32
33
 
33
34
  class AdminController(Controller):
@@ -74,10 +75,10 @@ class AdminController(Controller):
74
75
  if not ctx["admin_nav"]:
75
76
  raise NotAuthorizedException("No admin pages accessible")
76
77
 
77
- flash = request.session.pop("flash", None)
78
+ flash_messages = get_flash_messages(request)
78
79
  return TemplateResponse(
79
80
  "admin/admin.html",
80
- context={"flash": flash, **ctx},
81
+ context={"flash_messages": flash_messages, **ctx},
81
82
  )
82
83
 
83
84
  @get(
@@ -100,10 +101,10 @@ class AdminController(Controller):
100
101
  )
101
102
  users = list(result.scalars().all())
102
103
 
103
- flash = request.session.pop("flash", None)
104
+ flash_messages = get_flash_messages(request)
104
105
  return TemplateResponse(
105
106
  "admin/users/list.html",
106
- context={"flash": flash, "users": users, **ctx},
107
+ context={"flash_messages": flash_messages, "users": users, **ctx},
107
108
  )
108
109
 
109
110
  @get(
@@ -129,11 +130,11 @@ class AdminController(Controller):
129
130
  # Get user's current role names
130
131
  current_roles = {role.name for role in target_user.roles}
131
132
 
132
- flash = request.session.pop("flash", None)
133
+ flash_messages = get_flash_messages(request)
133
134
  return TemplateResponse(
134
135
  "admin/users/roles.html",
135
136
  context={
136
- "flash": flash,
137
+ "flash_messages": flash_messages,
137
138
  "target_user": target_user,
138
139
  "current_roles": current_roles,
139
140
  "available_roles": ROLE_DEFINITIONS,
@@ -200,18 +201,18 @@ class AdminController(Controller):
200
201
  """List all pages with management actions."""
201
202
  ctx = await self._get_admin_context(request, db_session)
202
203
 
203
- # Get all pages with their authors
204
+ # Get all pages with their authors, ordered by display order then created
204
205
  result = await db_session.execute(
205
206
  select(Page)
206
207
  .options(selectinload(Page.user))
207
- .order_by(Page.created_at.desc())
208
+ .order_by(Page.order.asc(), Page.created_at.desc())
208
209
  )
209
210
  pages = list(result.scalars().all())
210
211
 
211
- flash = request.session.pop("flash", None)
212
+ flash_messages = get_flash_messages(request)
212
213
  return TemplateResponse(
213
214
  "admin/pages/list.html",
214
- context={"flash": flash, "pages": pages, **ctx},
215
+ context={"flash_messages": flash_messages, "pages": pages, **ctx},
215
216
  )
216
217
 
217
218
  @get(
@@ -223,10 +224,10 @@ class AdminController(Controller):
223
224
  ) -> TemplateResponse:
224
225
  """Show new page form."""
225
226
  ctx = await self._get_admin_context(request, db_session)
226
- flash = request.session.pop("flash", None)
227
+ flash_messages = get_flash_messages(request)
227
228
  return TemplateResponse(
228
229
  "admin/pages/edit.html",
229
- context={"flash": flash, "page": None, **ctx},
230
+ context={"flash_messages": flash_messages, "page": None, **ctx},
230
231
  )
231
232
 
232
233
  @post(
@@ -245,8 +246,20 @@ class AdminController(Controller):
245
246
  content = data.get("content", "").strip()
246
247
  is_published = data.get("is_published") == "on"
247
248
 
249
+ # New fields
250
+ order = int(data.get("order", 0) or 0)
251
+ publish_at_str = data.get("publish_at", "").strip()
252
+ publish_at = datetime.fromisoformat(publish_at_str) if publish_at_str else None
253
+
254
+ # SEO fields
255
+ meta_description = data.get("meta_description", "").strip() or None
256
+ og_title = data.get("og_title", "").strip() or None
257
+ og_description = data.get("og_description", "").strip() or None
258
+ og_image = data.get("og_image", "").strip() or None
259
+ meta_robots = data.get("meta_robots", "").strip() or None
260
+
248
261
  if not title or not slug:
249
- request.session["flash"] = "Title and slug are required"
262
+ flash_error(request, "Title and slug are required")
250
263
  return Redirect(path="/admin/pages/new")
251
264
 
252
265
  published_at = datetime.now(UTC) if is_published else None
@@ -259,11 +272,18 @@ class AdminController(Controller):
259
272
  content=content,
260
273
  is_published=is_published,
261
274
  published_at=published_at,
275
+ order=order,
276
+ publish_at=publish_at,
277
+ meta_description=meta_description,
278
+ og_title=og_title,
279
+ og_description=og_description,
280
+ og_image=og_image,
281
+ meta_robots=meta_robots,
262
282
  )
263
- request.session["flash"] = f"Page '{title}' created successfully!"
283
+ flash_success(request, f"Page '{title}' created successfully!")
264
284
  return Redirect(path="/admin/pages")
265
285
  except Exception as e:
266
- request.session["flash"] = f"Error creating page: {str(e)}"
286
+ flash_error(request, f"Error creating page: {str(e)}")
267
287
  return Redirect(path="/admin/pages/new")
268
288
 
269
289
  @get(
@@ -278,13 +298,13 @@ class AdminController(Controller):
278
298
 
279
299
  page = await page_service.get_page_by_id(db_session, page_id)
280
300
  if not page:
281
- request.session["flash"] = "Page not found"
301
+ flash_error(request, "Page not found")
282
302
  return Redirect(path="/admin/pages")
283
303
 
284
- flash = request.session.pop("flash", None)
304
+ flash_messages = get_flash_messages(request)
285
305
  return TemplateResponse(
286
306
  "admin/pages/edit.html",
287
- context={"flash": flash, "page": page, **ctx},
307
+ context={"flash_messages": flash_messages, "page": page, **ctx},
288
308
  )
289
309
 
290
310
  @post(
@@ -304,13 +324,25 @@ class AdminController(Controller):
304
324
  content = data.get("content", "").strip()
305
325
  is_published = data.get("is_published") == "on"
306
326
 
327
+ # New fields
328
+ order = int(data.get("order", 0) or 0)
329
+ publish_at_str = data.get("publish_at", "").strip()
330
+ publish_at = datetime.fromisoformat(publish_at_str) if publish_at_str else None
331
+
332
+ # SEO fields
333
+ meta_description = data.get("meta_description", "").strip() or None
334
+ og_title = data.get("og_title", "").strip() or None
335
+ og_description = data.get("og_description", "").strip() or None
336
+ og_image = data.get("og_image", "").strip() or None
337
+ meta_robots = data.get("meta_robots", "").strip() or None
338
+
307
339
  if not title or not slug:
308
- request.session["flash"] = "Title and slug are required"
340
+ flash_error(request, "Title and slug are required")
309
341
  return Redirect(path=f"/admin/pages/{page_id}/edit")
310
342
 
311
343
  page = await page_service.get_page_by_id(db_session, page_id)
312
344
  if not page:
313
- request.session["flash"] = "Page not found"
345
+ flash_error(request, "Page not found")
314
346
  return Redirect(path="/admin/pages")
315
347
 
316
348
  published_at = page.published_at
@@ -326,11 +358,18 @@ class AdminController(Controller):
326
358
  content=content,
327
359
  is_published=is_published,
328
360
  published_at=published_at,
361
+ order=order,
362
+ publish_at=publish_at,
363
+ meta_description=meta_description,
364
+ og_title=og_title,
365
+ og_description=og_description,
366
+ og_image=og_image,
367
+ meta_robots=meta_robots,
329
368
  )
330
- request.session["flash"] = f"Page '{title}' updated successfully!"
369
+ flash_success(request, f"Page '{title}' updated successfully!")
331
370
  return Redirect(path="/admin/pages")
332
371
  except Exception as e:
333
- request.session["flash"] = f"Error updating page: {str(e)}"
372
+ flash_error(request, f"Error updating page: {str(e)}")
334
373
  return Redirect(path=f"/admin/pages/{page_id}/edit")
335
374
 
336
375
  @post(
@@ -397,6 +436,55 @@ class AdminController(Controller):
397
436
  request.session["flash"] = f"'{page_title}' has been deleted"
398
437
  return Redirect(path="/admin/pages")
399
438
 
439
+ @get(
440
+ "/pages/{page_id:uuid}/revisions",
441
+ guards=[auth_guard, Permission("manage-pages")],
442
+ )
443
+ async def list_revisions(
444
+ self, request: Request, db_session: AsyncSession, page_id: UUID
445
+ ) -> TemplateResponse:
446
+ """List revisions for a page."""
447
+ ctx = await self._get_admin_context(request, db_session)
448
+
449
+ page = await page_service.get_page_by_id(db_session, page_id)
450
+ if not page:
451
+ flash_error(request, "Page not found")
452
+ return Redirect(path="/admin/pages")
453
+
454
+ revisions = await revision_service.list_revisions(db_session, page_id)
455
+
456
+ flash_messages = get_flash_messages(request)
457
+ return TemplateResponse(
458
+ "admin/pages/revisions.html",
459
+ context={"flash_messages": flash_messages, "page": page, "revisions": revisions, **ctx},
460
+ )
461
+
462
+ @post(
463
+ "/pages/{page_id:uuid}/revisions/{revision_id:uuid}/restore",
464
+ guards=[auth_guard, Permission("manage-pages")],
465
+ )
466
+ async def restore_revision(
467
+ self, request: Request, db_session: AsyncSession, page_id: UUID, revision_id: UUID
468
+ ) -> Redirect:
469
+ """Restore a page to a previous revision."""
470
+ page = await page_service.get_page_by_id(db_session, page_id)
471
+ if not page:
472
+ flash_error(request, "Page not found")
473
+ return Redirect(path="/admin/pages")
474
+
475
+ revision = await revision_service.get_revision(db_session, revision_id)
476
+ if not revision or revision.page_id != page_id:
477
+ flash_error(request, "Revision not found")
478
+ return Redirect(path=f"/admin/pages/{page_id}/revisions")
479
+
480
+ user_id = request.session.get("user_id")
481
+ await revision_service.restore_revision(
482
+ db_session, page, revision, UUID(user_id) if user_id else None
483
+ )
484
+
485
+ flash_success(request, f"Page restored to revision #{revision.revision_number}")
486
+ return Redirect(path=f"/admin/pages/{page_id}/edit")
487
+
400
488
  @get(
401
489
  "/settings",
402
490
  tags=[ADMIN_NAV_TAG],
@@ -410,10 +498,10 @@ class AdminController(Controller):
410
498
  ctx = await self._get_admin_context(request, db_session)
411
499
  site_settings = await setting_service.get_site_settings(db_session)
412
500
 
413
- flash = request.session.pop("flash", None)
501
+ flash_messages = get_flash_messages(request)
414
502
  return TemplateResponse(
415
503
  "admin/settings/site.html",
416
- context={"flash": flash, "settings": site_settings, **ctx},
504
+ context={"flash_messages": flash_messages, "settings": site_settings, **ctx},
417
505
  )
418
506
 
419
507
  @post(
@@ -448,5 +536,5 @@ class AdminController(Controller):
448
536
  # Refresh the site settings cache
449
537
  await setting_service.load_site_settings_cache(db_session)
450
538
 
451
- request.session["flash"] = "Site settings saved successfully"
539
+ flash_success(request, "Site settings saved successfully")
452
540
  return Redirect(path="/admin/settings")
@@ -0,0 +1,35 @@
1
+ """add content scheduling
2
+
3
+ Revision ID: 5e6f7g8h9i0j
4
+ Revises: 4d5e6f7g8h9i
5
+ Create Date: 2026-02-02 10:02:00.000000
6
+
7
+ This migration adds the publish_at column for scheduled publishing.
8
+ When set, pages with is_published=True will only be visible after this datetime.
9
+ """
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import advanced_alchemy.types
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = '5e6f7g8h9i0j'
19
+ down_revision: Union[str, None] = '4d5e6f7g8h9i'
20
+ branch_labels: Union[str, Sequence[str], None] = None
21
+ depends_on: Union[str, Sequence[str], None] = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ with op.batch_alter_table('pages') as batch_op:
26
+ batch_op.add_column(
27
+ sa.Column('publish_at', advanced_alchemy.types.datetime.DateTimeUTC(timezone=True), nullable=True)
28
+ )
29
+ batch_op.create_index('ix_pages_publish_at', ['publish_at'])
30
+
31
+
32
+ def downgrade() -> None:
33
+ with op.batch_alter_table('pages') as batch_op:
34
+ batch_op.drop_index('ix_pages_publish_at')
35
+ batch_op.drop_column('publish_at')
@@ -0,0 +1,32 @@
1
+ """add page ordering
2
+
3
+ Revision ID: 4d5e6f7g8h9i
4
+ Revises: 3c4d5e6f7g8h
5
+ Create Date: 2026-02-02 10:01:00.000000
6
+
7
+ This migration adds an order column to the pages table for custom ordering.
8
+ Lower numbers appear first, default is 0.
9
+ """
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+
16
+ # revision identifiers, used by Alembic.
17
+ revision: str = '4d5e6f7g8h9i'
18
+ down_revision: Union[str, None] = '3c4d5e6f7g8h'
19
+ branch_labels: Union[str, Sequence[str], None] = None
20
+ depends_on: Union[str, Sequence[str], None] = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ with op.batch_alter_table('pages') as batch_op:
25
+ batch_op.add_column(sa.Column('order', sa.Integer(), nullable=False, server_default='0'))
26
+ batch_op.create_index('ix_pages_order', ['order'])
27
+
28
+
29
+ def downgrade() -> None:
30
+ with op.batch_alter_table('pages') as batch_op:
31
+ batch_op.drop_index('ix_pages_order')
32
+ batch_op.drop_column('order')
@@ -0,0 +1,46 @@
1
+ """add page revisions table
2
+
3
+ Revision ID: 6f7g8h9i0j1k
4
+ Revises: 5e6f7g8h9i0j
5
+ Create Date: 2026-02-02 10:03:00.000000
6
+
7
+ This migration creates the page_revisions table for tracking content history.
8
+ Revisions are created when page content is modified, allowing restoration.
9
+ """
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import advanced_alchemy.types
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = '6f7g8h9i0j1k'
19
+ down_revision: Union[str, None] = '5e6f7g8h9i0j'
20
+ branch_labels: Union[str, Sequence[str], None] = None
21
+ depends_on: Union[str, Sequence[str], None] = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ op.create_table('page_revisions',
26
+ sa.Column('id', advanced_alchemy.types.guid.GUID(length=16), nullable=False),
27
+ sa.Column('page_id', advanced_alchemy.types.guid.GUID(length=16), nullable=False),
28
+ sa.Column('user_id', advanced_alchemy.types.guid.GUID(length=16), nullable=True),
29
+ sa.Column('revision_number', sa.Integer(), nullable=False),
30
+ sa.Column('title', sa.String(length=500), nullable=False),
31
+ sa.Column('content', sa.Text(), nullable=False),
32
+ sa.Column('created_at', advanced_alchemy.types.datetime.DateTimeUTC(timezone=True), nullable=False),
33
+ sa.Column('sa_orm_sentinel', sa.Integer(), nullable=True),
34
+ sa.Column('updated_at', advanced_alchemy.types.datetime.DateTimeUTC(timezone=True), nullable=False),
35
+ sa.ForeignKeyConstraint(['page_id'], ['pages.id'], name=op.f('fk_page_revisions_page_id_pages'), ondelete='CASCADE'),
36
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_page_revisions_user_id_users'), ondelete='SET NULL'),
37
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_page_revisions'))
38
+ )
39
+ op.create_index(op.f('ix_page_revisions_page_id'), 'page_revisions', ['page_id'], unique=False)
40
+ op.create_index(op.f('ix_page_revisions_user_id'), 'page_revisions', ['user_id'], unique=False)
41
+
42
+
43
+ def downgrade() -> None:
44
+ op.drop_index(op.f('ix_page_revisions_user_id'), table_name='page_revisions')
45
+ op.drop_index(op.f('ix_page_revisions_page_id'), table_name='page_revisions')
46
+ op.drop_table('page_revisions')
@@ -0,0 +1,42 @@
1
+ """add SEO fields to pages
2
+
3
+ Revision ID: 3c4d5e6f7g8h
4
+ Revises: 2b3c4d5e6f7g
5
+ Create Date: 2026-02-02 10:00:00.000000
6
+
7
+ This migration adds SEO metadata fields to the pages table:
8
+ - meta_description: Meta description for search engines
9
+ - og_title: OpenGraph title override
10
+ - og_description: OpenGraph description override
11
+ - og_image: OpenGraph image URL
12
+ - meta_robots: Meta robots directive (noindex, nofollow, etc.)
13
+ """
14
+ from typing import Sequence, Union
15
+
16
+ from alembic import op
17
+ import sqlalchemy as sa
18
+
19
+
20
+ # revision identifiers, used by Alembic.
21
+ revision: str = '3c4d5e6f7g8h'
22
+ down_revision: Union[str, None] = '2b3c4d5e6f7g'
23
+ branch_labels: Union[str, Sequence[str], None] = None
24
+ depends_on: Union[str, Sequence[str], None] = None
25
+
26
+
27
+ def upgrade() -> None:
28
+ with op.batch_alter_table('pages') as batch_op:
29
+ batch_op.add_column(sa.Column('meta_description', sa.Text(), nullable=True))
30
+ batch_op.add_column(sa.Column('og_title', sa.String(length=500), nullable=True))
31
+ batch_op.add_column(sa.Column('og_description', sa.Text(), nullable=True))
32
+ batch_op.add_column(sa.Column('og_image', sa.String(length=1024), nullable=True))
33
+ batch_op.add_column(sa.Column('meta_robots', sa.String(length=100), nullable=True))
34
+
35
+
36
+ def downgrade() -> None:
37
+ with op.batch_alter_table('pages') as batch_op:
38
+ batch_op.drop_column('meta_robots')
39
+ batch_op.drop_column('og_image')
40
+ batch_op.drop_column('og_description')
41
+ batch_op.drop_column('og_title')
42
+ batch_op.drop_column('meta_description')
@@ -0,0 +1,205 @@
1
+ ---
2
+ name: skrift
3
+ description: "Help working with Skrift CMS codebases - a Python async CMS built on Litestar with WordPress-style conventions. Use for creating controllers, models, hooks, pages, and templates."
4
+ ---
5
+
6
+ # Skrift CMS Development Guide
7
+
8
+ Skrift is a lightweight async Python CMS built on Litestar, featuring WordPress-style template resolution, a hook/filter extensibility system, and SQLAlchemy async database access.
9
+
10
+ ## Current Project State
11
+
12
+ **Configuration:**
13
+ !`cat app.yaml 2>/dev/null || echo "No app.yaml found"`
14
+
15
+ **Controllers:**
16
+ !`ls skrift/controllers/*.py 2>/dev/null | head -10`
17
+
18
+ **Models:**
19
+ !`ls skrift/db/models/*.py 2>/dev/null | head -10`
20
+
21
+ **Services:**
22
+ !`ls skrift/db/services/*.py 2>/dev/null | head -10`
23
+
24
+ **Templates:**
25
+ !`ls templates/*.html 2>/dev/null | head -10 || echo "No custom templates"`
26
+
27
+ ## Quick Reference
28
+
29
+ ### Core Architecture
30
+
31
+ - **Framework**: Litestar (async Python web framework)
32
+ - **Database**: SQLAlchemy async with Advanced Alchemy
33
+ - **Templates**: Jinja2 with WordPress-style template hierarchy
34
+ - **Config**: YAML (app.yaml) + environment variables (.env)
35
+ - **Auth**: OAuth providers + role-based permissions
36
+
37
+ ### Key Files
38
+
39
+ | File | Purpose |
40
+ |------|---------|
41
+ | `skrift/asgi.py` | AppDispatcher, app creation, middleware loading |
42
+ | `skrift/config.py` | Settings management, YAML config loading |
43
+ | `skrift/cli.py` | CLI commands (serve, secret, db) |
44
+ | `skrift/lib/hooks.py` | WordPress-like hook/filter system |
45
+ | `skrift/lib/template.py` | Template resolution with fallbacks |
46
+ | `skrift/db/base.py` | SQLAlchemy Base class (UUIDAuditBase) |
47
+ | `skrift/auth/` | Guards, roles, permissions |
48
+
49
+ ### CLI Commands
50
+
51
+ ```bash
52
+ # Run development server
53
+ skrift serve --reload --port 8080
54
+
55
+ # Generate secret key
56
+ skrift secret # Print to stdout
57
+ skrift secret --write .env # Write to .env file
58
+
59
+ # Database migrations (wraps Alembic)
60
+ skrift db upgrade head # Apply all migrations
61
+ skrift db downgrade -1 # Rollback one migration
62
+ skrift db current # Show current revision
63
+ skrift db revision -m "desc" --autogenerate # Create migration
64
+ ```
65
+
66
+ ## Task-Specific Guidance
67
+
68
+ ### Creating a Controller
69
+
70
+ Controllers are Litestar Controller classes registered in `app.yaml`:
71
+
72
+ ```python
73
+ from litestar import Controller, get, post
74
+ from litestar.response import Template as TemplateResponse
75
+ from sqlalchemy.ext.asyncio import AsyncSession
76
+
77
+ class MyController(Controller):
78
+ path = "/my-path"
79
+
80
+ @get("/")
81
+ async def list_items(self, db_session: AsyncSession) -> TemplateResponse:
82
+ # db_session is injected automatically
83
+ return TemplateResponse("my-template.html", context={"items": []})
84
+ ```
85
+
86
+ Register in `app.yaml`:
87
+ ```yaml
88
+ controllers:
89
+ - myapp.controllers:MyController
90
+ ```
91
+
92
+ ### Creating a Database Model
93
+
94
+ Models inherit from `skrift.db.base.Base` (provides id, created_at, updated_at):
95
+
96
+ ```python
97
+ from sqlalchemy import String, Text
98
+ from sqlalchemy.orm import Mapped, mapped_column
99
+ from skrift.db.base import Base
100
+
101
+ class MyModel(Base):
102
+ __tablename__ = "my_models"
103
+
104
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
105
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
106
+ ```
107
+
108
+ ### Creating a Service
109
+
110
+ Services are async functions that handle database operations:
111
+
112
+ ```python
113
+ from sqlalchemy import select
114
+ from sqlalchemy.ext.asyncio import AsyncSession
115
+ from skrift.db.models import MyModel
116
+
117
+ async def get_item_by_id(db_session: AsyncSession, item_id: UUID) -> MyModel | None:
118
+ result = await db_session.execute(select(MyModel).where(MyModel.id == item_id))
119
+ return result.scalar_one_or_none()
120
+
121
+ async def create_item(db_session: AsyncSession, name: str) -> MyModel:
122
+ item = MyModel(name=name)
123
+ db_session.add(item)
124
+ await db_session.commit()
125
+ await db_session.refresh(item)
126
+ return item
127
+ ```
128
+
129
+ ### Using Hooks
130
+
131
+ The hook system provides WordPress-like extensibility:
132
+
133
+ ```python
134
+ from skrift.lib.hooks import hooks, action, filter
135
+
136
+ # Action: Side effects (no return value needed)
137
+ @action("after_page_save", priority=10)
138
+ async def notify_on_save(page, is_new: bool):
139
+ print(f"Page saved: {page.title}")
140
+
141
+ # Filter: Transform values (must return value)
142
+ @filter("page_seo_meta", priority=10)
143
+ async def customize_meta(meta: dict, page) -> dict:
144
+ meta["author"] = "Custom Author"
145
+ return meta
146
+
147
+ # Trigger hooks programmatically
148
+ await hooks.do_action("my_action", arg1, arg2)
149
+ result = await hooks.apply_filters("my_filter", initial_value, arg1)
150
+ ```
151
+
152
+ Built-in hooks:
153
+ - Actions: `before_page_save`, `after_page_save`, `before_page_delete`, `after_page_delete`
154
+ - Filters: `page_seo_meta`, `page_og_meta`, `sitemap_urls`, `sitemap_page`, `robots_txt`, `template_context`
155
+
156
+ ### Using Guards (Authorization)
157
+
158
+ Protect routes with permission or role guards:
159
+
160
+ ```python
161
+ from skrift.auth import auth_guard, Permission, Role
162
+
163
+ class AdminController(Controller):
164
+ path = "/admin"
165
+ guards = [auth_guard, Permission("manage-pages")]
166
+
167
+ @get("/")
168
+ async def admin_dashboard(self) -> TemplateResponse:
169
+ return TemplateResponse("admin/dashboard.html")
170
+ ```
171
+
172
+ Combine requirements:
173
+ ```python
174
+ # AND: Both required
175
+ guards = [auth_guard, Permission("edit") & Permission("publish")]
176
+
177
+ # OR: Either sufficient
178
+ guards = [auth_guard, Role("admin") | Role("editor")]
179
+ ```
180
+
181
+ ### Template Resolution
182
+
183
+ WordPress-style template hierarchy with fallbacks:
184
+
185
+ ```python
186
+ from skrift.lib.template import Template
187
+
188
+ # Tries: page-about.html -> page.html
189
+ template = Template("page", "about")
190
+
191
+ # Tries: post-news-2024.html -> post-news.html -> post.html
192
+ template = Template("post", "news", "2024")
193
+
194
+ return template.render(TEMPLATE_DIR, page=page, extra="context")
195
+ ```
196
+
197
+ Templates searched in order:
198
+ 1. `./templates/` (project directory - user overrides)
199
+ 2. `skrift/templates/` (package directory - defaults)
200
+
201
+ ## Reference Documentation
202
+
203
+ For detailed documentation, see:
204
+ - [Architecture Details](architecture.md)
205
+ - [Code Patterns](patterns.md)
File without changes