shrd 0.3.0__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.
- shard/__init__.py +35 -0
- shard/actions.py +21 -0
- shard/apps.py +12 -0
- shard/component.py +342 -0
- shard/conf.py +16 -0
- shard/context_processors.py +28 -0
- shard/css_minify.py +53 -0
- shard/exceptions.py +22 -0
- shard/htmx.py +53 -0
- shard/management/commands/shard_list.py +26 -0
- shard/management/commands/shard_report.py +43 -0
- shard/props.py +81 -0
- shard/py.typed +0 -0
- shard/registry.py +60 -0
- shard/render.py +60 -0
- shard/scoping.py +87 -0
- shard/slots.py +25 -0
- shard/state.py +69 -0
- shard/static/shard/js/alpine.min.js +5 -0
- shard/static/shard/js/htmx.min.js +1 -0
- shard/static/shard/js/shard.js +13 -0
- shard/styles.py +79 -0
- shard/templates/shard/component_shell.html +1 -0
- shard/templates/shard/scripts.html +13 -0
- shard/templatetags/shard.py +180 -0
- shard/urls.py +14 -0
- shard/view_data.py +233 -0
- shard/view_tree.py +89 -0
- shard/views.py +68 -0
- shard/weight.py +218 -0
- shrd-0.3.0.dist-info/METADATA +129 -0
- shrd-0.3.0.dist-info/RECORD +35 -0
- shrd-0.3.0.dist-info/WHEEL +5 -0
- shrd-0.3.0.dist-info/licenses/LICENSE +21 -0
- shrd-0.3.0.dist-info/top_level.txt +1 -0
shard/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shard — a Django-native component framework."""
|
|
2
|
+
|
|
3
|
+
from shard.actions import ActionResult
|
|
4
|
+
from shard.component import Component, action, computed, emits
|
|
5
|
+
from shard.props import Prop
|
|
6
|
+
from shard.render import mount, render_component
|
|
7
|
+
from shard.view_data import (
|
|
8
|
+
ViewNode,
|
|
9
|
+
commit_view_tree,
|
|
10
|
+
ensure_node_ids,
|
|
11
|
+
get_slot_nodes,
|
|
12
|
+
render_view_data,
|
|
13
|
+
set_slot_nodes,
|
|
14
|
+
)
|
|
15
|
+
from shard.view_tree import ViewTreeComponent
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ActionResult",
|
|
19
|
+
"Component",
|
|
20
|
+
"Prop",
|
|
21
|
+
"ViewNode",
|
|
22
|
+
"ViewTreeComponent",
|
|
23
|
+
"action",
|
|
24
|
+
"commit_view_tree",
|
|
25
|
+
"computed",
|
|
26
|
+
"emits",
|
|
27
|
+
"ensure_node_ids",
|
|
28
|
+
"get_slot_nodes",
|
|
29
|
+
"mount",
|
|
30
|
+
"render_component",
|
|
31
|
+
"render_view_data",
|
|
32
|
+
"set_slot_nodes",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
__version__ = "0.3.0"
|
shard/actions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ActionResult:
|
|
9
|
+
"""Optional return value from an ``@action`` method.
|
|
10
|
+
|
|
11
|
+
Use when an action needs to emit HTMX events or trigger a redirect in
|
|
12
|
+
addition to updating server state.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
state: dict[str, Any] | None = None
|
|
16
|
+
events: dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
redirect: str | None = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def with_events(cls, state: dict[str, Any], **events: Any) -> ActionResult:
|
|
21
|
+
return cls(state=state, events=events)
|
shard/apps.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ShardConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "shard"
|
|
7
|
+
verbose_name = "Shard Component Framework"
|
|
8
|
+
|
|
9
|
+
def ready(self) -> None:
|
|
10
|
+
from shard.registry import autodiscover_components
|
|
11
|
+
|
|
12
|
+
autodiscover_components()
|
shard/component.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from django.template import Context, Template
|
|
9
|
+
from django.template.loader import render_to_string
|
|
10
|
+
from django.utils.safestring import SafeString, mark_safe
|
|
11
|
+
|
|
12
|
+
from shard.actions import ActionResult
|
|
13
|
+
from shard.exceptions import ActionNotFoundError, PropValidationError
|
|
14
|
+
from shard.props import Prop
|
|
15
|
+
from shard.slots import SlotContent
|
|
16
|
+
from shard.state import StateStore
|
|
17
|
+
from shard.styles import component_scope, load_scoped_styles
|
|
18
|
+
|
|
19
|
+
ACTION_MARKER = "__shard_action__"
|
|
20
|
+
COMPUTED_MARKER = "__shard_computed__"
|
|
21
|
+
EMITS_MARKER = "__shard_emits__"
|
|
22
|
+
IGNORED_PAYLOAD_KEYS = frozenset({"csrfmiddlewaretoken", "shard"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def action(method: Callable) -> Callable:
|
|
26
|
+
"""Mark a component method as an HTMX-callable server action."""
|
|
27
|
+
|
|
28
|
+
@wraps(method)
|
|
29
|
+
def wrapper(self, state: dict[str, Any], *args, **kwargs) -> Any:
|
|
30
|
+
return method(self, state, *args, **kwargs)
|
|
31
|
+
|
|
32
|
+
setattr(wrapper, ACTION_MARKER, True)
|
|
33
|
+
if hasattr(method, EMITS_MARKER):
|
|
34
|
+
setattr(wrapper, EMITS_MARKER, getattr(method, EMITS_MARKER))
|
|
35
|
+
return wrapper
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def computed(method: Callable) -> Callable:
|
|
39
|
+
"""Expose a method's return value as ``computed.<name>`` in templates."""
|
|
40
|
+
|
|
41
|
+
@wraps(method)
|
|
42
|
+
def wrapper(self) -> Any:
|
|
43
|
+
return method(self)
|
|
44
|
+
|
|
45
|
+
setattr(wrapper, COMPUTED_MARKER, True)
|
|
46
|
+
return wrapper
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def emits(*event_names: str) -> Callable:
|
|
50
|
+
"""Declare HTMX events fired after an action completes."""
|
|
51
|
+
|
|
52
|
+
def decorator(method: Callable) -> Callable:
|
|
53
|
+
setattr(method, EMITS_MARKER, tuple(event_names))
|
|
54
|
+
return method
|
|
55
|
+
|
|
56
|
+
return decorator
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ComponentMeta(type):
|
|
60
|
+
def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any]):
|
|
61
|
+
prop_fields: dict[str, Prop] = {}
|
|
62
|
+
computed_fields: dict[str, Callable] = {}
|
|
63
|
+
|
|
64
|
+
for base in reversed(bases):
|
|
65
|
+
prop_fields.update(getattr(base, "_prop_fields", {}))
|
|
66
|
+
computed_fields.update(getattr(base, "_computed_fields", {}))
|
|
67
|
+
|
|
68
|
+
for key, value in list(namespace.items()):
|
|
69
|
+
if isinstance(value, Prop):
|
|
70
|
+
prop_fields[key] = value.bind(key)
|
|
71
|
+
namespace[key] = prop_fields[key]
|
|
72
|
+
elif callable(value) and getattr(value, COMPUTED_MARKER, False):
|
|
73
|
+
computed_fields[key] = value
|
|
74
|
+
|
|
75
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
76
|
+
cls._prop_fields = prop_fields
|
|
77
|
+
cls._computed_fields = computed_fields
|
|
78
|
+
cls.component_name = namespace.get("component_name", name)
|
|
79
|
+
return cls
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Component(metaclass=ComponentMeta):
|
|
83
|
+
"""Base class for Shard components."""
|
|
84
|
+
|
|
85
|
+
template_name: str = ""
|
|
86
|
+
component_name: str = ""
|
|
87
|
+
scope: str = ""
|
|
88
|
+
stylesheets: list[str] | None = None
|
|
89
|
+
styles: str = ""
|
|
90
|
+
scoped_styles: bool = True
|
|
91
|
+
_prop_fields: ClassVar[dict[str, Prop]] = {}
|
|
92
|
+
_computed_fields: ClassVar[dict[str, Callable]] = {}
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
instance_id: str | None = None,
|
|
98
|
+
props: dict[str, Any] | None = None,
|
|
99
|
+
state: dict[str, Any] | None = None,
|
|
100
|
+
slots: dict[str, str] | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.instance_id = instance_id or uuid4().hex
|
|
103
|
+
self._raw_props = props or {}
|
|
104
|
+
self._props = self._resolve_props(self._raw_props)
|
|
105
|
+
self._state = state if state is not None else self.get_initial_state()
|
|
106
|
+
self._slots = SlotContent.from_mapping(slots)
|
|
107
|
+
self._pending_events: dict[str, Any] = {}
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def prop_names(cls) -> list[str]:
|
|
111
|
+
return list(cls._prop_fields.keys())
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def action_names(cls) -> list[str]:
|
|
115
|
+
return [
|
|
116
|
+
name
|
|
117
|
+
for name in dir(cls)
|
|
118
|
+
if name != "dispatch_action"
|
|
119
|
+
and callable(getattr(cls, name))
|
|
120
|
+
and getattr(getattr(cls, name), ACTION_MARKER, False)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
def get_initial_state(self) -> dict[str, Any]:
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
def get_client_state(self) -> dict[str, Any]:
|
|
127
|
+
"""Optional Alpine.js state. Override to seed ``x-data``."""
|
|
128
|
+
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
def before_action(self, action_name: str, payload: dict[str, Any]) -> None:
|
|
132
|
+
"""Hook called before an action handler runs."""
|
|
133
|
+
|
|
134
|
+
def after_action(self, action_name: str, state: dict[str, Any]) -> None:
|
|
135
|
+
"""Hook called after an action handler runs."""
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def shard_scope(self) -> str:
|
|
139
|
+
return component_scope(self.component_name, self.scope)
|
|
140
|
+
|
|
141
|
+
def get_computed_data(self) -> dict[str, Any]:
|
|
142
|
+
return {name: method(self) for name, method in type(self)._computed_fields.items()}
|
|
143
|
+
|
|
144
|
+
def get_context_data(self) -> dict[str, Any]:
|
|
145
|
+
return {
|
|
146
|
+
"component": self,
|
|
147
|
+
"props": self.props,
|
|
148
|
+
"state": self.state,
|
|
149
|
+
"computed": self.get_computed_data(),
|
|
150
|
+
"slots": self._slots.as_dict(),
|
|
151
|
+
"shard_id": self.instance_id,
|
|
152
|
+
"shard_scope": self.shard_scope,
|
|
153
|
+
"shard_actions": self.action_urls(),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def render(self, request=None) -> SafeString:
|
|
157
|
+
if not self.template_name:
|
|
158
|
+
raise ValueError(f"{self.__class__.__name__} is missing template_name.")
|
|
159
|
+
|
|
160
|
+
context = self.get_context_data()
|
|
161
|
+
if request is not None:
|
|
162
|
+
context["request"] = request
|
|
163
|
+
|
|
164
|
+
html = render_to_string(self.template_name, context, request=request)
|
|
165
|
+
styles = ""
|
|
166
|
+
if self._should_include_styles(request):
|
|
167
|
+
styles = load_scoped_styles(
|
|
168
|
+
scope=self.shard_scope,
|
|
169
|
+
template_name=self.template_name,
|
|
170
|
+
stylesheets=self.stylesheets,
|
|
171
|
+
inline_styles=self.styles,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return mark_safe(f"{styles}{html}")
|
|
175
|
+
|
|
176
|
+
def _should_include_styles(self, request) -> bool:
|
|
177
|
+
if not self.scoped_styles:
|
|
178
|
+
return False
|
|
179
|
+
if request is not None and request.headers.get("HX-Request"):
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
def render_child(
|
|
184
|
+
self,
|
|
185
|
+
component: type[Component] | str,
|
|
186
|
+
*,
|
|
187
|
+
props: dict[str, Any] | None = None,
|
|
188
|
+
slots: dict[str, str] | None = None,
|
|
189
|
+
request=None,
|
|
190
|
+
) -> SafeString:
|
|
191
|
+
"""Render a nested child component from Python or templates."""
|
|
192
|
+
|
|
193
|
+
from shard.registry import get_component
|
|
194
|
+
from shard.render import render_component
|
|
195
|
+
|
|
196
|
+
component_cls = get_component(component) if isinstance(component, str) else component
|
|
197
|
+
return render_component(
|
|
198
|
+
component_cls,
|
|
199
|
+
props=props,
|
|
200
|
+
slots=slots,
|
|
201
|
+
request=request,
|
|
202
|
+
persist=True,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def dispatch_action(
|
|
206
|
+
self, action_name: str, payload: dict[str, Any] | None = None
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
handler = getattr(type(self), action_name, None)
|
|
209
|
+
if handler is None or not getattr(handler, ACTION_MARKER, False):
|
|
210
|
+
raise ActionNotFoundError(
|
|
211
|
+
f"Action '{action_name}' is not defined on {self.__class__.__name__}."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
clean_payload = {
|
|
215
|
+
key: value for key, value in (payload or {}).items() if key not in IGNORED_PAYLOAD_KEYS
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
self.before_action(action_name, clean_payload)
|
|
219
|
+
raw_result = handler(self, dict(self._state), **clean_payload)
|
|
220
|
+
next_state, events, redirect = self._normalize_action_result(raw_result)
|
|
221
|
+
|
|
222
|
+
declared_events = getattr(handler, EMITS_MARKER, ())
|
|
223
|
+
for event_name in declared_events:
|
|
224
|
+
events.setdefault(event_name, True)
|
|
225
|
+
|
|
226
|
+
self._pending_events = events
|
|
227
|
+
self._pending_redirect = redirect
|
|
228
|
+
self.after_action(action_name, next_state)
|
|
229
|
+
return next_state
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def pending_events(self) -> dict[str, Any]:
|
|
233
|
+
return dict(self._pending_events)
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def pending_redirect(self) -> str | None:
|
|
237
|
+
return getattr(self, "_pending_redirect", None)
|
|
238
|
+
|
|
239
|
+
def _normalize_action_result(
|
|
240
|
+
self,
|
|
241
|
+
raw_result: Any,
|
|
242
|
+
) -> tuple[dict[str, Any], dict[str, Any], str | None]:
|
|
243
|
+
if isinstance(raw_result, ActionResult):
|
|
244
|
+
state = raw_result.state if raw_result.state is not None else dict(self._state)
|
|
245
|
+
return state, dict(raw_result.events), raw_result.redirect
|
|
246
|
+
|
|
247
|
+
if raw_result is None:
|
|
248
|
+
return dict(self._state), {}, None
|
|
249
|
+
|
|
250
|
+
if isinstance(raw_result, dict):
|
|
251
|
+
return raw_result, {}, None
|
|
252
|
+
|
|
253
|
+
raise TypeError(
|
|
254
|
+
f"Action handlers must return a state dict or ActionResult, not {type(raw_result)!r}."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def persist(self) -> None:
|
|
258
|
+
StateStore.save(
|
|
259
|
+
instance_id=self.instance_id,
|
|
260
|
+
component_name=self.component_name,
|
|
261
|
+
props=self._raw_props,
|
|
262
|
+
state=self._state,
|
|
263
|
+
slots=self._slots.as_dict(),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def from_storage(cls, instance_id: str) -> Component:
|
|
268
|
+
record = StateStore.load(instance_id)
|
|
269
|
+
component_cls = cls.registry_get(record.component_name)
|
|
270
|
+
return component_cls(
|
|
271
|
+
instance_id=instance_id,
|
|
272
|
+
props=record.props,
|
|
273
|
+
state=record.state,
|
|
274
|
+
slots=record.slots,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def registry_get(cls, component_name: str) -> type[Component]:
|
|
279
|
+
from shard.registry import get_component
|
|
280
|
+
|
|
281
|
+
return get_component(component_name)
|
|
282
|
+
|
|
283
|
+
def action_urls(self) -> dict[str, str]:
|
|
284
|
+
from django.urls import reverse
|
|
285
|
+
|
|
286
|
+
from shard.conf import get_setting
|
|
287
|
+
|
|
288
|
+
namespace = get_setting("URL_NAMESPACE")
|
|
289
|
+
return {
|
|
290
|
+
name: reverse(f"{namespace}:action", args=[self.instance_id, name])
|
|
291
|
+
for name in self.action_names()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def props(self) -> dict[str, Any]:
|
|
296
|
+
return dict(self._props)
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def state(self) -> dict[str, Any]:
|
|
300
|
+
return dict(self._state)
|
|
301
|
+
|
|
302
|
+
@state.setter
|
|
303
|
+
def state(self, value: dict[str, Any]) -> None:
|
|
304
|
+
self._state = dict(value)
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def slots(self) -> dict[str, str]:
|
|
308
|
+
return self._slots.as_dict()
|
|
309
|
+
|
|
310
|
+
def _resolve_props(self, raw: dict[str, Any]) -> dict[str, Any]:
|
|
311
|
+
resolved: dict[str, Any] = {}
|
|
312
|
+
errors: list[str] = []
|
|
313
|
+
|
|
314
|
+
for name, prop in self._prop_fields.items():
|
|
315
|
+
try:
|
|
316
|
+
resolved[name] = prop.resolve(raw.get(name))
|
|
317
|
+
except PropValidationError as exc:
|
|
318
|
+
errors.append(str(exc))
|
|
319
|
+
|
|
320
|
+
for name in raw:
|
|
321
|
+
if name not in self._prop_fields:
|
|
322
|
+
errors.append(f"Unknown prop '{name}' for {self.__class__.__name__}.")
|
|
323
|
+
|
|
324
|
+
if errors:
|
|
325
|
+
raise PropValidationError("; ".join(errors))
|
|
326
|
+
|
|
327
|
+
return resolved
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class TemplateComponent(Component):
|
|
331
|
+
"""Component with an inline Django template string."""
|
|
332
|
+
|
|
333
|
+
inline_template: str = ""
|
|
334
|
+
|
|
335
|
+
def render(self, request=None) -> SafeString:
|
|
336
|
+
if self.inline_template:
|
|
337
|
+
template = Template(self.inline_template)
|
|
338
|
+
context = Context(self.get_context_data())
|
|
339
|
+
if request is not None:
|
|
340
|
+
context["request"] = request
|
|
341
|
+
return mark_safe(template.render(context))
|
|
342
|
+
return super().render(request=request)
|
shard/conf.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
DEFAULTS = {
|
|
4
|
+
"STATE_BACKEND": "cache",
|
|
5
|
+
"STATE_TIMEOUT": 60 * 60 * 24,
|
|
6
|
+
"URL_NAMESPACE": "shard",
|
|
7
|
+
"AUTODISCOVER": True,
|
|
8
|
+
"LOAD_ALPINE": False,
|
|
9
|
+
"PRELOAD_SCRIPTS": True,
|
|
10
|
+
"MINIFY_CSS": True,
|
|
11
|
+
"VIEW_DATA_ALLOWED_COMPONENTS": None,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_setting(name: str):
|
|
16
|
+
return getattr(settings, f"SHARD_{name}", DEFAULTS[name])
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
|
|
7
|
+
from shard.conf import get_setting
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def shard(request) -> dict[str, Any]:
|
|
11
|
+
"""Expose Shard settings to every template."""
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
"SHARD": {
|
|
15
|
+
"version": _framework_version(),
|
|
16
|
+
"url_namespace": get_setting("URL_NAMESPACE"),
|
|
17
|
+
"debug": getattr(settings, "DEBUG", False),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _framework_version() -> str:
|
|
23
|
+
try:
|
|
24
|
+
from shard import __version__
|
|
25
|
+
|
|
26
|
+
return __version__
|
|
27
|
+
except Exception:
|
|
28
|
+
return "0.0.0"
|
shard/css_minify.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_PUNCTUATION_RE = re.compile(r"\s*([{}:;,>+~])\s*")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def minify_css(css: str) -> str:
|
|
9
|
+
"""Remove comments and collapse whitespace from component CSS."""
|
|
10
|
+
|
|
11
|
+
stripped = _strip_comments(css)
|
|
12
|
+
compact = re.sub(r"\s+", " ", stripped)
|
|
13
|
+
compact = _PUNCTUATION_RE.sub(r"\1", compact)
|
|
14
|
+
return compact.strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _strip_comments(css: str) -> str:
|
|
18
|
+
parts: list[str] = []
|
|
19
|
+
index = 0
|
|
20
|
+
length = len(css)
|
|
21
|
+
|
|
22
|
+
while index < length:
|
|
23
|
+
if css[index] in "\"'":
|
|
24
|
+
index, chunk = _read_quoted(css, index)
|
|
25
|
+
parts.append(chunk)
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
if css.startswith("/*", index):
|
|
29
|
+
end = css.find("*/", index + 2)
|
|
30
|
+
index = end + 2 if end != -1 else length
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
parts.append(css[index])
|
|
34
|
+
index += 1
|
|
35
|
+
|
|
36
|
+
return "".join(parts)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_quoted(css: str, start: int) -> tuple[int, str]:
|
|
40
|
+
quote = css[start]
|
|
41
|
+
index = start + 1
|
|
42
|
+
length = len(css)
|
|
43
|
+
|
|
44
|
+
while index < length:
|
|
45
|
+
if css[index] == "\\":
|
|
46
|
+
index += 2
|
|
47
|
+
continue
|
|
48
|
+
if css[index] == quote:
|
|
49
|
+
index += 1
|
|
50
|
+
break
|
|
51
|
+
index += 1
|
|
52
|
+
|
|
53
|
+
return index, css[start:index]
|
shard/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class ShardError(Exception):
|
|
2
|
+
"""Base error for the Shard framework."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ComponentNotFoundError(ShardError):
|
|
6
|
+
"""Raised when a component cannot be resolved from the registry."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PropValidationError(ShardError):
|
|
10
|
+
"""Raised when component props fail validation."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ActionNotFoundError(ShardError):
|
|
14
|
+
"""Raised when an HTMX action does not exist on a component."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StateNotFoundError(ShardError):
|
|
18
|
+
"""Raised when component state cannot be loaded for an instance."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ViewDataError(ShardError):
|
|
22
|
+
"""Raised when view data is invalid or references a disallowed component."""
|
shard/htmx.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django.utils.html import escape
|
|
7
|
+
from django.utils.safestring import SafeString, mark_safe
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_htmx_attrs(
|
|
11
|
+
component: Any,
|
|
12
|
+
action: str,
|
|
13
|
+
*,
|
|
14
|
+
swap: str = "outerHTML",
|
|
15
|
+
target: str | None = None,
|
|
16
|
+
trigger: str | None = None,
|
|
17
|
+
vals: dict[str, Any] | None = None,
|
|
18
|
+
include: str | None = None,
|
|
19
|
+
) -> SafeString:
|
|
20
|
+
"""Build HTMX attributes for a component action."""
|
|
21
|
+
|
|
22
|
+
url = component.action_urls().get(action)
|
|
23
|
+
if not url:
|
|
24
|
+
return mark_safe("")
|
|
25
|
+
|
|
26
|
+
target_id = target or f"#shard-{component.instance_id}"
|
|
27
|
+
attrs: list[str] = [
|
|
28
|
+
f'hx-post="{escape(url)}"',
|
|
29
|
+
f'hx-target="{escape(target_id)}"',
|
|
30
|
+
f'hx-swap="{escape(swap)}"',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
if trigger:
|
|
34
|
+
attrs.append(f'hx-trigger="{escape(trigger)}"')
|
|
35
|
+
if include:
|
|
36
|
+
attrs.append(f'hx-include="{escape(include)}"')
|
|
37
|
+
if vals:
|
|
38
|
+
attrs.append(f"hx-vals='{escape(json.dumps(vals))}'")
|
|
39
|
+
|
|
40
|
+
return mark_safe(" ".join(attrs))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_alpine_data(component: Any, extra: dict[str, Any] | None = None) -> SafeString:
|
|
44
|
+
"""Serialize ``get_client_state()`` for an Alpine.js ``x-data`` attribute."""
|
|
45
|
+
|
|
46
|
+
data: dict[str, Any] = {}
|
|
47
|
+
if hasattr(component, "get_client_state"):
|
|
48
|
+
data.update(component.get_client_state())
|
|
49
|
+
if extra:
|
|
50
|
+
data.update(extra)
|
|
51
|
+
|
|
52
|
+
payload = json.dumps(data)
|
|
53
|
+
return mark_safe(f"x-data='{escape(payload)}'")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
|
|
3
|
+
from shard.registry import get_all_components
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = "List registered Shard components."
|
|
8
|
+
|
|
9
|
+
def handle(self, *args, **options):
|
|
10
|
+
components = get_all_components()
|
|
11
|
+
if not components:
|
|
12
|
+
self.stdout.write("No components registered.")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
name_width = max(len(name) for name in components)
|
|
16
|
+
self.stdout.write(f"{'NAME'.ljust(name_width)} CLASS")
|
|
17
|
+
self.stdout.write("-" * (name_width + 10))
|
|
18
|
+
|
|
19
|
+
for name, component_cls in sorted(components.items()):
|
|
20
|
+
props = ", ".join(component_cls.prop_names()) or "—"
|
|
21
|
+
actions = ", ".join(component_cls.action_names()) or "—"
|
|
22
|
+
self.stdout.write(
|
|
23
|
+
f"{name.ljust(name_width)} {component_cls.__module__}.{component_cls.__name__}"
|
|
24
|
+
)
|
|
25
|
+
self.stdout.write(f"{'':<{name_width}} props: {props}")
|
|
26
|
+
self.stdout.write(f"{'':<{name_width}} actions: {actions}")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from django.core.management.base import BaseCommand
|
|
5
|
+
|
|
6
|
+
from shard.weight import build_report, check_budget, format_report, report_as_dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Command(BaseCommand):
|
|
10
|
+
help = "Report Shard framework size and page-load weight."
|
|
11
|
+
|
|
12
|
+
def add_arguments(self, parser):
|
|
13
|
+
parser.add_argument(
|
|
14
|
+
"--json",
|
|
15
|
+
action="store_true",
|
|
16
|
+
help="Output machine-readable JSON.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--check-budget",
|
|
20
|
+
action="store_true",
|
|
21
|
+
help="Exit with code 1 if bundled assets exceed size budgets.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def handle(self, *args, **options):
|
|
25
|
+
report = build_report()
|
|
26
|
+
violations = check_budget(report) if options["check_budget"] else []
|
|
27
|
+
|
|
28
|
+
if options["json"]:
|
|
29
|
+
payload = report_as_dict(report)
|
|
30
|
+
if options["check_budget"]:
|
|
31
|
+
payload["budget_violations"] = violations
|
|
32
|
+
self.stdout.write(json.dumps(payload, indent=2))
|
|
33
|
+
if violations:
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
self.stdout.write(format_report(report))
|
|
38
|
+
|
|
39
|
+
if violations:
|
|
40
|
+
self.stderr.write(self.style.ERROR("\nSize budget violations:"))
|
|
41
|
+
for violation in violations:
|
|
42
|
+
self.stderr.write(self.style.ERROR(f" - {violation}"))
|
|
43
|
+
sys.exit(1)
|