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
skrift/admin/controller.py
CHANGED
|
@@ -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
|
-
|
|
78
|
+
flash_messages = get_flash_messages(request)
|
|
78
79
|
return TemplateResponse(
|
|
79
80
|
"admin/admin.html",
|
|
80
|
-
context={"
|
|
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
|
-
|
|
104
|
+
flash_messages = get_flash_messages(request)
|
|
104
105
|
return TemplateResponse(
|
|
105
106
|
"admin/users/list.html",
|
|
106
|
-
context={"
|
|
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
|
-
|
|
133
|
+
flash_messages = get_flash_messages(request)
|
|
133
134
|
return TemplateResponse(
|
|
134
135
|
"admin/users/roles.html",
|
|
135
136
|
context={
|
|
136
|
-
"
|
|
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
|
-
|
|
212
|
+
flash_messages = get_flash_messages(request)
|
|
212
213
|
return TemplateResponse(
|
|
213
214
|
"admin/pages/list.html",
|
|
214
|
-
context={"
|
|
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
|
-
|
|
227
|
+
flash_messages = get_flash_messages(request)
|
|
227
228
|
return TemplateResponse(
|
|
228
229
|
"admin/pages/edit.html",
|
|
229
|
-
context={"
|
|
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
|
|
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
|
|
283
|
+
flash_success(request, f"Page '{title}' created successfully!")
|
|
264
284
|
return Redirect(path="/admin/pages")
|
|
265
285
|
except Exception as e:
|
|
266
|
-
request
|
|
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
|
|
301
|
+
flash_error(request, "Page not found")
|
|
282
302
|
return Redirect(path="/admin/pages")
|
|
283
303
|
|
|
284
|
-
|
|
304
|
+
flash_messages = get_flash_messages(request)
|
|
285
305
|
return TemplateResponse(
|
|
286
306
|
"admin/pages/edit.html",
|
|
287
|
-
context={"
|
|
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
|
|
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
|
|
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
|
|
369
|
+
flash_success(request, f"Page '{title}' updated successfully!")
|
|
331
370
|
return Redirect(path="/admin/pages")
|
|
332
371
|
except Exception as e:
|
|
333
|
-
request
|
|
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
|
-
|
|
501
|
+
flash_messages = get_flash_messages(request)
|
|
414
502
|
return TemplateResponse(
|
|
415
503
|
"admin/settings/site.html",
|
|
416
|
-
context={"
|
|
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
|
|
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
|