skrift 0.1.0a13__tar.gz → 0.1.0a15__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.
Files changed (90) hide show
  1. {skrift-0.1.0a13 → skrift-0.1.0a15}/PKG-INFO +16 -3
  2. {skrift-0.1.0a13 → skrift-0.1.0a15}/README.md +13 -2
  3. {skrift-0.1.0a13 → skrift-0.1.0a15}/pyproject.toml +3 -1
  4. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/admin/controller.py +114 -26
  5. skrift-0.1.0a15/skrift/alembic/versions/20260202_add_content_scheduling.py +35 -0
  6. skrift-0.1.0a15/skrift/alembic/versions/20260202_add_page_ordering.py +32 -0
  7. skrift-0.1.0a15/skrift/alembic/versions/20260202_add_page_revisions.py +46 -0
  8. skrift-0.1.0a15/skrift/alembic/versions/20260202_add_seo_fields.py +42 -0
  9. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/asgi.py +20 -10
  10. skrift-0.1.0a15/skrift/controllers/sitemap.py +116 -0
  11. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/controllers/web.py +18 -1
  12. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/models/__init__.py +2 -1
  13. skrift-0.1.0a15/skrift/db/models/page.py +51 -0
  14. skrift-0.1.0a15/skrift/db/models/page_revision.py +45 -0
  15. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/services/page_service.py +113 -6
  16. skrift-0.1.0a15/skrift/db/services/revision_service.py +146 -0
  17. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/services/setting_service.py +7 -0
  18. skrift-0.1.0a15/skrift/lib/__init__.py +15 -0
  19. skrift-0.1.0a15/skrift/lib/flash.py +103 -0
  20. skrift-0.1.0a15/skrift/lib/hooks.py +292 -0
  21. skrift-0.1.0a15/skrift/lib/markdown.py +37 -0
  22. skrift-0.1.0a15/skrift/lib/seo.py +103 -0
  23. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/static/css/style.css +92 -2
  24. skrift-0.1.0a15/skrift/templates/admin/pages/edit.html +72 -0
  25. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/admin/pages/list.html +8 -1
  26. skrift-0.1.0a15/skrift/templates/admin/pages/revisions.html +59 -0
  27. skrift-0.1.0a15/skrift/templates/base.html +106 -0
  28. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/page.html +1 -1
  29. skrift-0.1.0a13/skrift/db/models/page.py +0 -26
  30. skrift-0.1.0a13/skrift/lib/__init__.py +0 -3
  31. skrift-0.1.0a13/skrift/templates/admin/pages/edit.html +0 -32
  32. skrift-0.1.0a13/skrift/templates/base.html +0 -52
  33. {skrift-0.1.0a13 → skrift-0.1.0a15}/.gitignore +0 -0
  34. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/__init__.py +0 -0
  35. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/__main__.py +0 -0
  36. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/admin/__init__.py +0 -0
  37. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/admin/navigation.py +0 -0
  38. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/env.py +0 -0
  39. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/script.py.mako +0 -0
  40. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
  41. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
  42. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
  43. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
  44. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
  45. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/versions/20260129_add_oauth_accounts.py +0 -0
  46. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic/versions/20260129_add_provider_metadata.py +0 -0
  47. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/alembic.ini +0 -0
  48. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/auth/__init__.py +0 -0
  49. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/auth/guards.py +0 -0
  50. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/auth/roles.py +0 -0
  51. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/auth/services.py +0 -0
  52. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/cli.py +0 -0
  53. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/config.py +0 -0
  54. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/controllers/__init__.py +0 -0
  55. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/controllers/auth.py +0 -0
  56. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/__init__.py +0 -0
  57. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/base.py +0 -0
  58. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/models/oauth_account.py +0 -0
  59. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/models/role.py +0 -0
  60. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/models/setting.py +0 -0
  61. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/models/user.py +0 -0
  62. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/services/__init__.py +0 -0
  63. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/db/services/oauth_service.py +0 -0
  64. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/lib/exceptions.py +0 -0
  65. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/lib/template.py +0 -0
  66. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/setup/__init__.py +0 -0
  67. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/setup/config_writer.py +0 -0
  68. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/setup/controller.py +0 -0
  69. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/setup/middleware.py +0 -0
  70. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/setup/providers.py +0 -0
  71. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/setup/state.py +0 -0
  72. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/admin/admin.html +0 -0
  73. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/admin/base.html +0 -0
  74. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/admin/settings/site.html +0 -0
  75. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/admin/users/list.html +0 -0
  76. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/admin/users/roles.html +0 -0
  77. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/auth/dummy_login.html +0 -0
  78. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/auth/login.html +0 -0
  79. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/error-404.html +0 -0
  80. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/error-500.html +0 -0
  81. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/error.html +0 -0
  82. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/index.html +0 -0
  83. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/admin.html +0 -0
  84. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/auth.html +0 -0
  85. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/base.html +0 -0
  86. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/complete.html +0 -0
  87. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/configuring.html +0 -0
  88. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/database.html +0 -0
  89. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/restart.html +0 -0
  90. {skrift-0.1.0a13 → skrift-0.1.0a15}/skrift/templates/setup/site.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a13
3
+ Version: 0.1.0a15
4
4
  Summary: A lightweight async Python CMS for crafting modern websites
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: advanced-alchemy>=0.26.0
@@ -9,6 +9,8 @@ Requires-Dist: alembic>=1.14.0
9
9
  Requires-Dist: asyncpg>=0.30.0
10
10
  Requires-Dist: httpx>=0.28.0
11
11
  Requires-Dist: litestar[cryptography,jinja,standard]>=2.14.0
12
+ Requires-Dist: markdown-it-py>=3.0.0
13
+ Requires-Dist: mdit-py-plugins>=0.4.0
12
14
  Requires-Dist: pydantic-settings>=2.7.0
13
15
  Requires-Dist: python-dotenv>=1.0.0
14
16
  Requires-Dist: pyyaml>=6.0.0
@@ -33,6 +35,11 @@ A modern Litestar-powered content management framework with multi-provider OAuth
33
35
  - **Dynamic Controllers**: Load controllers from `app.yaml` configuration
34
36
  - **SQLAlchemy Integration**: Async database support with SQLite/PostgreSQL
35
37
  - **Client-Side Sessions**: Encrypted cookie sessions for horizontal scalability
38
+ - **Hook/Filter System**: WordPress-like extensibility with async support
39
+ - **SEO Metadata**: Built-in meta descriptions, OpenGraph tags, and robots directives
40
+ - **Content Scheduling**: Schedule pages to publish at a future date
41
+ - **Page Revisions**: Automatic content history with restore capability
42
+ - **Sitemap & Robots.txt**: Auto-generated with filter extensibility
36
43
 
37
44
  ## Quick Start
38
45
 
@@ -119,11 +126,17 @@ skrift/
119
126
  ├── skrift/ # Main Python package
120
127
  │ ├── asgi.py # Application factory
121
128
  │ ├── config.py # Settings management
122
- │ ├── controllers/ # Route handlers
129
+ │ ├── controllers/ # Route handlers (auth, web, sitemap)
123
130
  │ ├── admin/ # Admin panel
124
131
  │ ├── auth/ # RBAC and guards
125
132
  │ ├── db/ # Models and services
126
- │ ├── lib/ # Template resolver
133
+ ├── models/ # Page, User, Role, PageRevision
134
+ │ │ └── services/ # page_service, revision_service
135
+ │ ├── lib/ # Core utilities
136
+ │ │ ├── hooks.py # Hook/filter system
137
+ │ │ ├── seo.py # SEO metadata utilities
138
+ │ │ ├── flash.py # Enhanced flash messages
139
+ │ │ └── template.py # Template resolver
127
140
  │ └── setup/ # Setup wizard
128
141
  ├── templates/ # Jinja2 templates
129
142
  ├── static/ # Static assets
@@ -12,6 +12,11 @@ A modern Litestar-powered content management framework with multi-provider OAuth
12
12
  - **Dynamic Controllers**: Load controllers from `app.yaml` configuration
13
13
  - **SQLAlchemy Integration**: Async database support with SQLite/PostgreSQL
14
14
  - **Client-Side Sessions**: Encrypted cookie sessions for horizontal scalability
15
+ - **Hook/Filter System**: WordPress-like extensibility with async support
16
+ - **SEO Metadata**: Built-in meta descriptions, OpenGraph tags, and robots directives
17
+ - **Content Scheduling**: Schedule pages to publish at a future date
18
+ - **Page Revisions**: Automatic content history with restore capability
19
+ - **Sitemap & Robots.txt**: Auto-generated with filter extensibility
15
20
 
16
21
  ## Quick Start
17
22
 
@@ -98,11 +103,17 @@ skrift/
98
103
  ├── skrift/ # Main Python package
99
104
  │ ├── asgi.py # Application factory
100
105
  │ ├── config.py # Settings management
101
- │ ├── controllers/ # Route handlers
106
+ │ ├── controllers/ # Route handlers (auth, web, sitemap)
102
107
  │ ├── admin/ # Admin panel
103
108
  │ ├── auth/ # RBAC and guards
104
109
  │ ├── db/ # Models and services
105
- │ ├── lib/ # Template resolver
110
+ ├── models/ # Page, User, Role, PageRevision
111
+ │ │ └── services/ # page_service, revision_service
112
+ │ ├── lib/ # Core utilities
113
+ │ │ ├── hooks.py # Hook/filter system
114
+ │ │ ├── seo.py # SEO metadata utilities
115
+ │ │ ├── flash.py # Enhanced flash messages
116
+ │ │ └── template.py # Template resolver
106
117
  │ └── setup/ # Setup wizard
107
118
  ├── templates/ # Jinja2 templates
108
119
  ├── static/ # Static assets
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skrift"
3
- version = "0.1.0a13"
3
+ version = "0.1.0a15"
4
4
  description = "A lightweight async Python CMS for crafting modern websites"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,6 +17,8 @@ dependencies = [
17
17
  "uvicorn>=0.34.0",
18
18
  "pyyaml>=6.0.0",
19
19
  "ruamel.yaml>=0.18.0",
20
+ "markdown-it-py>=3.0.0",
21
+ "mdit-py-plugins>=0.4.0",
20
22
  ]
21
23
 
22
24
  [project.scripts]
@@ -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')
@@ -44,6 +44,7 @@ from skrift.db.services.setting_service import (
44
44
  SETUP_COMPLETED_AT_KEY,
45
45
  )
46
46
  from skrift.lib.exceptions import http_exception_handler, internal_server_error_handler
47
+ from skrift.lib.markdown import render_markdown
47
48
 
48
49
 
49
50
  def load_controllers() -> list:
@@ -423,16 +424,20 @@ def create_app() -> Litestar:
423
424
  # Search working directory first for user overrides, then package directory
424
425
  working_dir_templates = Path(os.getcwd()) / "templates"
425
426
  template_dir = Path(__file__).parent / "templates"
426
- template_config = TemplateConfig(
427
- directory=[working_dir_templates, template_dir],
428
- engine=JinjaTemplateEngine,
429
- engine_callback=lambda engine: engine.engine.globals.update({
427
+ def configure_template_engine(engine):
428
+ engine.engine.globals.update({
430
429
  "now": datetime.now,
431
430
  "site_name": get_cached_site_name,
432
431
  "site_tagline": get_cached_site_tagline,
433
432
  "site_copyright_holder": get_cached_site_copyright_holder,
434
433
  "site_copyright_start_year": get_cached_site_copyright_start_year,
435
- }),
434
+ })
435
+ engine.engine.filters.update({"markdown": render_markdown})
436
+
437
+ template_config = TemplateConfig(
438
+ directory=[working_dir_templates, template_dir],
439
+ engine=JinjaTemplateEngine,
440
+ engine_callback=configure_template_engine,
436
441
  )
437
442
 
438
443
  # Static files - working directory first for user overrides, then package directory
@@ -498,16 +503,21 @@ def create_setup_app() -> Litestar:
498
503
  # Search working directory first for user overrides, then package directory
499
504
  working_dir_templates = Path(os.getcwd()) / "templates"
500
505
  template_dir = Path(__file__).parent / "templates"
501
- template_config = TemplateConfig(
502
- directory=[working_dir_templates, template_dir],
503
- engine=JinjaTemplateEngine,
504
- engine_callback=lambda engine: engine.engine.globals.update({
506
+
507
+ def configure_setup_template_engine(engine):
508
+ engine.engine.globals.update({
505
509
  "now": datetime.now,
506
510
  "site_name": lambda: "Skrift",
507
511
  "site_tagline": lambda: "Setup",
508
512
  "site_copyright_holder": lambda: "",
509
513
  "site_copyright_start_year": lambda: None,
510
- }),
514
+ })
515
+ engine.engine.filters.update({"markdown": render_markdown})
516
+
517
+ template_config = TemplateConfig(
518
+ directory=[working_dir_templates, template_dir],
519
+ engine=JinjaTemplateEngine,
520
+ engine_callback=configure_setup_template_engine,
511
521
  )
512
522
 
513
523
  # Static files - working directory first for user overrides, then package directory