skrift 0.1.0a14__py3-none-any.whl → 0.1.0a15__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/lib/hooks.py ADDED
@@ -0,0 +1,292 @@
1
+ """WordPress-like hook/filter system for Skrift CMS extensibility.
2
+
3
+ This module provides an async-first hook system that allows registering
4
+ callbacks to be executed at specific points in the application lifecycle.
5
+
6
+ Actions: Execute callbacks without modifying a value (side effects)
7
+ Filters: Execute callbacks that can modify a value (transformations)
8
+
9
+ Usage:
10
+ from skrift.lib.hooks import hooks, action, filter
11
+
12
+ # Using decorators (auto-registered on import)
13
+ @action("after_page_save", priority=10)
14
+ async def notify_on_page_save(page):
15
+ print(f"Page saved: {page.title}")
16
+
17
+ @filter("page_seo_meta", priority=10)
18
+ async def add_custom_meta(meta, page):
19
+ meta["author"] = "Custom Author"
20
+ return meta
21
+
22
+ # Using direct registration
23
+ hooks.add_action("before_page_save", my_callback)
24
+ hooks.add_filter("page_og_meta", my_modifier)
25
+
26
+ # Triggering hooks
27
+ await hooks.do_action("after_page_save", page)
28
+ meta = await hooks.apply_filters("page_seo_meta", {}, page)
29
+ """
30
+
31
+ import asyncio
32
+ from collections import defaultdict
33
+ from dataclasses import dataclass, field
34
+ from functools import wraps
35
+ from typing import Any, Callable, TypeVar
36
+
37
+ T = TypeVar("T")
38
+
39
+
40
+ @dataclass(order=True)
41
+ class HookHandler:
42
+ """A registered hook handler with priority."""
43
+
44
+ priority: int
45
+ callback: Callable = field(compare=False)
46
+
47
+ async def call(self, *args: Any, **kwargs: Any) -> Any:
48
+ """Call the handler, handling both sync and async callbacks."""
49
+ result = self.callback(*args, **kwargs)
50
+ if asyncio.iscoroutine(result):
51
+ return await result
52
+ return result
53
+
54
+
55
+ class HookRegistry:
56
+ """Central registry for all hooks (actions and filters)."""
57
+
58
+ def __init__(self) -> None:
59
+ self._actions: dict[str, list[HookHandler]] = defaultdict(list)
60
+ self._filters: dict[str, list[HookHandler]] = defaultdict(list)
61
+
62
+ def add_action(
63
+ self,
64
+ hook_name: str,
65
+ callback: Callable[..., Any],
66
+ priority: int = 10,
67
+ ) -> None:
68
+ """Register an action callback.
69
+
70
+ Args:
71
+ hook_name: Name of the action hook
72
+ callback: Function to call when action is triggered
73
+ priority: Lower numbers execute first (default: 10)
74
+ """
75
+ handler = HookHandler(priority=priority, callback=callback)
76
+ self._actions[hook_name].append(handler)
77
+ self._actions[hook_name].sort()
78
+
79
+ def add_filter(
80
+ self,
81
+ hook_name: str,
82
+ callback: Callable[..., T],
83
+ priority: int = 10,
84
+ ) -> None:
85
+ """Register a filter callback.
86
+
87
+ Args:
88
+ hook_name: Name of the filter hook
89
+ callback: Function to call to modify value
90
+ priority: Lower numbers execute first (default: 10)
91
+ """
92
+ handler = HookHandler(priority=priority, callback=callback)
93
+ self._filters[hook_name].append(handler)
94
+ self._filters[hook_name].sort()
95
+
96
+ def remove_action(
97
+ self,
98
+ hook_name: str,
99
+ callback: Callable[..., Any],
100
+ ) -> bool:
101
+ """Remove an action callback.
102
+
103
+ Args:
104
+ hook_name: Name of the action hook
105
+ callback: Function to remove
106
+
107
+ Returns:
108
+ True if callback was found and removed
109
+ """
110
+ handlers = self._actions.get(hook_name, [])
111
+ for i, handler in enumerate(handlers):
112
+ if handler.callback is callback:
113
+ handlers.pop(i)
114
+ return True
115
+ return False
116
+
117
+ def remove_filter(
118
+ self,
119
+ hook_name: str,
120
+ callback: Callable[..., Any],
121
+ ) -> bool:
122
+ """Remove a filter callback.
123
+
124
+ Args:
125
+ hook_name: Name of the filter hook
126
+ callback: Function to remove
127
+
128
+ Returns:
129
+ True if callback was found and removed
130
+ """
131
+ handlers = self._filters.get(hook_name, [])
132
+ for i, handler in enumerate(handlers):
133
+ if handler.callback is callback:
134
+ handlers.pop(i)
135
+ return True
136
+ return False
137
+
138
+ def has_action(self, hook_name: str) -> bool:
139
+ """Check if any actions are registered for a hook."""
140
+ return bool(self._actions.get(hook_name))
141
+
142
+ def has_filter(self, hook_name: str) -> bool:
143
+ """Check if any filters are registered for a hook."""
144
+ return bool(self._filters.get(hook_name))
145
+
146
+ async def do_action(
147
+ self,
148
+ hook_name: str,
149
+ *args: Any,
150
+ **kwargs: Any,
151
+ ) -> None:
152
+ """Execute all registered action callbacks.
153
+
154
+ Args:
155
+ hook_name: Name of the action hook
156
+ *args: Positional arguments to pass to callbacks
157
+ **kwargs: Keyword arguments to pass to callbacks
158
+ """
159
+ handlers = self._actions.get(hook_name, [])
160
+ for handler in handlers:
161
+ await handler.call(*args, **kwargs)
162
+
163
+ async def apply_filters(
164
+ self,
165
+ hook_name: str,
166
+ value: T,
167
+ *args: Any,
168
+ **kwargs: Any,
169
+ ) -> T:
170
+ """Apply all registered filter callbacks to a value.
171
+
172
+ Args:
173
+ hook_name: Name of the filter hook
174
+ value: Initial value to filter
175
+ *args: Additional positional arguments to pass to callbacks
176
+ **kwargs: Keyword arguments to pass to callbacks
177
+
178
+ Returns:
179
+ The filtered value after all callbacks have been applied
180
+ """
181
+ handlers = self._filters.get(hook_name, [])
182
+ for handler in handlers:
183
+ value = await handler.call(value, *args, **kwargs)
184
+ return value
185
+
186
+ def clear(self) -> None:
187
+ """Clear all registered hooks. Useful for testing."""
188
+ self._actions.clear()
189
+ self._filters.clear()
190
+
191
+
192
+ # Global singleton registry
193
+ hooks = HookRegistry()
194
+
195
+
196
+ # Convenience functions that use the global registry
197
+ def add_action(
198
+ hook_name: str,
199
+ callback: Callable[..., Any],
200
+ priority: int = 10,
201
+ ) -> None:
202
+ """Register an action callback to the global registry."""
203
+ hooks.add_action(hook_name, callback, priority)
204
+
205
+
206
+ def add_filter(
207
+ hook_name: str,
208
+ callback: Callable[..., T],
209
+ priority: int = 10,
210
+ ) -> None:
211
+ """Register a filter callback to the global registry."""
212
+ hooks.add_filter(hook_name, callback, priority)
213
+
214
+
215
+ def remove_action(hook_name: str, callback: Callable[..., Any]) -> bool:
216
+ """Remove an action callback from the global registry."""
217
+ return hooks.remove_action(hook_name, callback)
218
+
219
+
220
+ def remove_filter(hook_name: str, callback: Callable[..., Any]) -> bool:
221
+ """Remove a filter callback from the global registry."""
222
+ return hooks.remove_filter(hook_name, callback)
223
+
224
+
225
+ async def do_action(hook_name: str, *args: Any, **kwargs: Any) -> None:
226
+ """Execute all registered action callbacks via the global registry."""
227
+ await hooks.do_action(hook_name, *args, **kwargs)
228
+
229
+
230
+ async def apply_filters(hook_name: str, value: T, *args: Any, **kwargs: Any) -> T:
231
+ """Apply all registered filter callbacks via the global registry."""
232
+ return await hooks.apply_filters(hook_name, value, *args, **kwargs)
233
+
234
+
235
+ # Decorator factories for auto-registration
236
+ def action(hook_name: str, priority: int = 10) -> Callable[[Callable], Callable]:
237
+ """Decorator to register a function as an action handler.
238
+
239
+ Usage:
240
+ @action("after_page_save", priority=5)
241
+ async def my_handler(page):
242
+ ...
243
+ """
244
+
245
+ def decorator(func: Callable) -> Callable:
246
+ hooks.add_action(hook_name, func, priority)
247
+
248
+ @wraps(func)
249
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
250
+ return func(*args, **kwargs)
251
+
252
+ return wrapper
253
+
254
+ return decorator
255
+
256
+
257
+ def filter(hook_name: str, priority: int = 10) -> Callable[[Callable], Callable]:
258
+ """Decorator to register a function as a filter handler.
259
+
260
+ Usage:
261
+ @filter("page_seo_meta", priority=5)
262
+ async def my_filter(meta, page):
263
+ meta["custom"] = "value"
264
+ return meta
265
+ """
266
+
267
+ def decorator(func: Callable) -> Callable:
268
+ hooks.add_filter(hook_name, func, priority)
269
+
270
+ @wraps(func)
271
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
272
+ return func(*args, **kwargs)
273
+
274
+ return wrapper
275
+
276
+ return decorator
277
+
278
+
279
+ # Define standard hook names as constants for discoverability
280
+ # Actions
281
+ BEFORE_PAGE_SAVE = "before_page_save"
282
+ AFTER_PAGE_SAVE = "after_page_save"
283
+ BEFORE_PAGE_DELETE = "before_page_delete"
284
+ AFTER_PAGE_DELETE = "after_page_delete"
285
+
286
+ # Filters
287
+ PAGE_SEO_META = "page_seo_meta"
288
+ PAGE_OG_META = "page_og_meta"
289
+ SITEMAP_URLS = "sitemap_urls"
290
+ SITEMAP_PAGE = "sitemap_page"
291
+ ROBOTS_TXT = "robots_txt"
292
+ TEMPLATE_CONTEXT = "template_context"
skrift/lib/seo.py ADDED
@@ -0,0 +1,103 @@
1
+ """SEO utilities for generating page metadata."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from skrift.lib.hooks import hooks, PAGE_SEO_META, PAGE_OG_META
7
+
8
+ if TYPE_CHECKING:
9
+ from skrift.db.models import Page
10
+
11
+
12
+ @dataclass
13
+ class SEOMeta:
14
+ """Standard SEO metadata for a page."""
15
+
16
+ title: str
17
+ description: str | None
18
+ canonical_url: str
19
+ robots: str | None
20
+
21
+
22
+ @dataclass
23
+ class OpenGraphMeta:
24
+ """OpenGraph metadata for social sharing."""
25
+
26
+ title: str
27
+ description: str | None
28
+ image: str | None
29
+ url: str
30
+ site_name: str
31
+ type: str = "website"
32
+
33
+
34
+ async def get_page_seo_meta(
35
+ page: "Page",
36
+ site_name: str,
37
+ base_url: str,
38
+ ) -> SEOMeta:
39
+ """Generate SEO metadata for a page.
40
+
41
+ Args:
42
+ page: The page to generate metadata for
43
+ site_name: The site name for title suffix
44
+ base_url: Base URL for canonical URL generation
45
+
46
+ Returns:
47
+ SEOMeta dataclass with the metadata
48
+ """
49
+ # Build canonical URL
50
+ slug = page.slug.strip("/")
51
+ canonical_url = f"{base_url.rstrip('/')}/{slug}" if slug else base_url.rstrip("/")
52
+
53
+ # Build title with site name suffix
54
+ title = f"{page.title} | {site_name}" if site_name else page.title
55
+
56
+ meta = SEOMeta(
57
+ title=title,
58
+ description=page.meta_description,
59
+ canonical_url=canonical_url,
60
+ robots=page.meta_robots,
61
+ )
62
+
63
+ # Apply filter for extensibility
64
+ meta = await hooks.apply_filters(PAGE_SEO_META, meta, page, site_name, base_url)
65
+
66
+ return meta
67
+
68
+
69
+ async def get_page_og_meta(
70
+ page: "Page",
71
+ site_name: str,
72
+ base_url: str,
73
+ ) -> OpenGraphMeta:
74
+ """Generate OpenGraph metadata for a page.
75
+
76
+ Args:
77
+ page: The page to generate metadata for
78
+ site_name: The site name
79
+ base_url: Base URL for URL generation
80
+
81
+ Returns:
82
+ OpenGraphMeta dataclass with the metadata
83
+ """
84
+ # Build URL
85
+ slug = page.slug.strip("/")
86
+ url = f"{base_url.rstrip('/')}/{slug}" if slug else base_url.rstrip("/")
87
+
88
+ # Use og_* fields if set, otherwise fall back to page fields
89
+ og_title = page.og_title or page.title
90
+ og_description = page.og_description or page.meta_description
91
+
92
+ meta = OpenGraphMeta(
93
+ title=og_title,
94
+ description=og_description,
95
+ image=page.og_image,
96
+ url=url,
97
+ site_name=site_name,
98
+ )
99
+
100
+ # Apply filter for extensibility
101
+ meta = await hooks.apply_filters(PAGE_OG_META, meta, page, site_name, base_url)
102
+
103
+ return meta
@@ -630,11 +630,96 @@ article footer {
630
630
 
631
631
  /* Flash messages */
632
632
  .flash-messages {
633
- margin-bottom: var(--spacing-lg);
633
+ position: fixed;
634
+ top: var(--spacing-md);
635
+ right: var(--spacing-md);
636
+ z-index: 1000;
637
+ max-width: 400px;
634
638
  }
635
639
 
636
- .flash-messages [role="alert"] {
640
+ .flash {
641
+ display: flex;
642
+ align-items: center;
643
+ justify-content: space-between;
644
+ padding: 0.75rem 1rem;
637
645
  margin-bottom: var(--spacing-sm);
646
+ border-radius: var(--radius-md);
647
+ box-shadow: var(--shadow-md);
648
+ animation: flash-slide-in 0.3s ease;
649
+ transition: opacity 0.3s ease;
650
+ }
651
+
652
+ .flash-success {
653
+ background-color: #d4edda;
654
+ border-left: 4px solid #28a745;
655
+ color: #155724;
656
+ }
657
+
658
+ .flash-error {
659
+ background-color: #f8d7da;
660
+ border-left: 4px solid #dc3545;
661
+ color: #721c24;
662
+ }
663
+
664
+ .flash-warning {
665
+ background-color: #fff3cd;
666
+ border-left: 4px solid #ffc107;
667
+ color: #856404;
668
+ }
669
+
670
+ .flash-info {
671
+ background-color: #d1ecf1;
672
+ border-left: 4px solid #17a2b8;
673
+ color: #0c5460;
674
+ }
675
+
676
+ /* Dark mode flash colors */
677
+ @media (prefers-color-scheme: dark) {
678
+ .flash-success { background-color: #1a3d24; color: #9ae6b4; }
679
+ .flash-error { background-color: #3d1a1a; color: #feb2b2; }
680
+ .flash-warning { background-color: #3d3d1a; color: #faf089; }
681
+ .flash-info { background-color: #1a3d3d; color: #90cdf4; }
682
+ }
683
+
684
+ [data-theme="dark"] .flash-success { background-color: #1a3d24; color: #9ae6b4; }
685
+ [data-theme="dark"] .flash-error { background-color: #3d1a1a; color: #feb2b2; }
686
+ [data-theme="dark"] .flash-warning { background-color: #3d3d1a; color: #faf089; }
687
+ [data-theme="dark"] .flash-info { background-color: #1a3d3d; color: #90cdf4; }
688
+
689
+ .flash-message {
690
+ flex: 1;
691
+ }
692
+
693
+ .flash-dismiss {
694
+ background: none;
695
+ border: none;
696
+ font-size: 1.25rem;
697
+ cursor: pointer;
698
+ opacity: 0.5;
699
+ margin-left: var(--spacing-sm);
700
+ padding: 0;
701
+ line-height: 1;
702
+ color: inherit;
703
+ box-shadow: none;
704
+ border-radius: 0;
705
+ }
706
+
707
+ .flash-dismiss:hover {
708
+ opacity: 1;
709
+ background: none;
710
+ box-shadow: none;
711
+ transform: none;
712
+ }
713
+
714
+ @keyframes flash-slide-in {
715
+ from {
716
+ transform: translateX(100%);
717
+ opacity: 0;
718
+ }
719
+ to {
720
+ transform: translateX(0);
721
+ opacity: 1;
722
+ }
638
723
  }
639
724
 
640
725
  /* ========================================
@@ -936,6 +1021,11 @@ main.container.admin-layout {
936
1021
  color: var(--color-text-muted);
937
1022
  }
938
1023
 
1024
+ .status-scheduled {
1025
+ background-color: #ffc107;
1026
+ color: #856404;
1027
+ }
1028
+
939
1029
  .type-badge {
940
1030
  display: inline-block;
941
1031
  padding: 0.125rem 0.5rem;
@@ -19,14 +19,54 @@
19
19
  <label for="content">Content</label>
20
20
  <textarea id="content" name="content" rows="15">{{ page.content if page else '' }}</textarea>
21
21
 
22
- <label>
23
- <input type="checkbox" name="is_published" {% if page and page.is_published %}checked{% endif %}>
24
- Published
25
- </label>
22
+ <fieldset class="form-group">
23
+ <legend>Publication</legend>
24
+
25
+ <label>
26
+ <input type="checkbox" name="is_published" {% if page and page.is_published %}checked{% endif %}>
27
+ Published
28
+ </label>
29
+
30
+ <label for="order">Display Order</label>
31
+ <input type="number" id="order" name="order" value="{{ page.order if page else 0 }}" min="0">
32
+ <small class="text-muted">Lower numbers appear first (default: 0)</small>
33
+
34
+ <label for="publish_at">Schedule Publish</label>
35
+ <input type="datetime-local" id="publish_at" name="publish_at" value="{{ page.publish_at.strftime('%Y-%m-%dT%H:%M') if page and page.publish_at else '' }}">
36
+ <small class="text-muted">Leave empty to publish immediately when published is checked</small>
37
+ </fieldset>
38
+
39
+ <details class="seo-settings">
40
+ <summary>SEO Settings</summary>
41
+ <fieldset>
42
+ <label for="meta_description">Meta Description</label>
43
+ <textarea id="meta_description" name="meta_description" rows="3" maxlength="320" placeholder="Brief description for search engines (max 320 chars)">{{ page.meta_description if page else '' }}</textarea>
44
+
45
+ <label for="meta_robots">Meta Robots</label>
46
+ <select id="meta_robots" name="meta_robots">
47
+ <option value="">Default (index, follow)</option>
48
+ <option value="noindex" {% if page and page.meta_robots == 'noindex' %}selected{% endif %}>noindex</option>
49
+ <option value="nofollow" {% if page and page.meta_robots == 'nofollow' %}selected{% endif %}>nofollow</option>
50
+ <option value="noindex, nofollow" {% if page and page.meta_robots == 'noindex, nofollow' %}selected{% endif %}>noindex, nofollow</option>
51
+ </select>
52
+
53
+ <label for="og_title">OpenGraph Title</label>
54
+ <input type="text" id="og_title" name="og_title" value="{{ page.og_title if page else '' }}" placeholder="Custom title for social sharing (uses page title if empty)">
55
+
56
+ <label for="og_description">OpenGraph Description</label>
57
+ <textarea id="og_description" name="og_description" rows="2" placeholder="Custom description for social sharing">{{ page.og_description if page else '' }}</textarea>
58
+
59
+ <label for="og_image">OpenGraph Image URL</label>
60
+ <input type="url" id="og_image" name="og_image" value="{{ page.og_image if page else '' }}" placeholder="https://example.com/image.jpg">
61
+ </fieldset>
62
+ </details>
26
63
 
27
64
  <div class="form-actions">
28
65
  <button type="submit">{{ "Update" if page else "Create" }} Page</button>
29
66
  <a href="/admin/pages" role="button" class="outline">Cancel</a>
67
+ {% if page %}
68
+ <a href="/admin/pages/{{ page.id }}/revisions" role="button" class="outline secondary">View History</a>
69
+ {% endif %}
30
70
  </div>
31
71
  </form>
32
72
  {% endblock %}
@@ -14,6 +14,7 @@
14
14
  <table class="admin-table">
15
15
  <thead>
16
16
  <tr>
17
+ <th>Order</th>
17
18
  <th>Title</th>
18
19
  <th>Slug</th>
19
20
  <th>Status</th>
@@ -24,13 +25,19 @@
24
25
  <tbody>
25
26
  {% for page in pages %}
26
27
  <tr>
28
+ <td class="text-muted">{{ page.order }}</td>
27
29
  <td>
28
30
  <a href="/{{ page.slug }}">{{ page.title }}</a>
29
31
  </td>
30
32
  <td class="text-muted">/{{ page.slug }}</td>
31
33
  <td>
32
34
  {% if page.is_published %}
33
- <span class="status-badge status-published">Published</span>
35
+ {% if page.publish_at and page.publish_at > now() %}
36
+ <span class="status-badge status-scheduled">Scheduled</span>
37
+ <small class="text-muted">{{ page.publish_at.strftime('%b %d, %Y %H:%M') }}</small>
38
+ {% else %}
39
+ <span class="status-badge status-published">Published</span>
40
+ {% endif %}
34
41
  {% else %}
35
42
  <span class="status-badge status-draft">Draft</span>
36
43
  {% endif %}
@@ -0,0 +1,59 @@
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block title %}Revision History - {{ page.title }} - Admin - {{ site_name() }}{% endblock %}
4
+
5
+ {% block admin_content %}
6
+ <hgroup>
7
+ <h1>Revision History</h1>
8
+ <p>{{ page.title }}</p>
9
+ </hgroup>
10
+
11
+ <div class="form-actions" style="margin-bottom: 1rem;">
12
+ <a href="/admin/pages/{{ page.id }}/edit" role="button" class="outline">&larr; Back to Edit</a>
13
+ </div>
14
+
15
+ {% if revisions %}
16
+ <table class="admin-table">
17
+ <thead>
18
+ <tr>
19
+ <th>#</th>
20
+ <th>Title</th>
21
+ <th>Changed By</th>
22
+ <th>Date</th>
23
+ <th>Actions</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ {% for revision in revisions %}
28
+ <tr>
29
+ <td>{{ revision.revision_number }}</td>
30
+ <td>{{ revision.title }}</td>
31
+ <td>
32
+ {% if revision.user %}
33
+ {{ revision.user.name or revision.user.email }}
34
+ {% else %}
35
+ <span class="text-muted">System</span>
36
+ {% endif %}
37
+ </td>
38
+ <td>{{ revision.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
39
+ <td class="actions-cell">
40
+ <form method="post" action="/admin/pages/{{ page.id }}/revisions/{{ revision.id }}/restore" class="inline-form" onsubmit="return confirm('Restore to revision #{{ revision.revision_number }}? This will create a new revision of the current state first.');">
41
+ <button type="submit" class="outline small">Restore</button>
42
+ </form>
43
+ </td>
44
+ </tr>
45
+ {% endfor %}
46
+ </tbody>
47
+ </table>
48
+ {% else %}
49
+ <p class="no-items">No revisions yet. Revisions are created when you save changes to a page.</p>
50
+ {% endif %}
51
+
52
+ <details style="margin-top: 2rem;">
53
+ <summary>Current Content Preview</summary>
54
+ <div class="revision-preview" style="background: var(--surface-color); padding: 1rem; border-radius: 4px; margin-top: 0.5rem;">
55
+ <h3>{{ page.title }}</h3>
56
+ <div>{{ page.content | markdown | safe }}</div>
57
+ </div>
58
+ </details>
59
+ {% endblock %}