django-wireview 0.2.1__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.
Files changed (48) hide show
  1. django_wireview-0.2.1.dist-info/METADATA +1199 -0
  2. django_wireview-0.2.1.dist-info/RECORD +48 -0
  3. django_wireview-0.2.1.dist-info/WHEEL +4 -0
  4. wireview/__init__.py +117 -0
  5. wireview/apps.py +34 -0
  6. wireview/async_result.py +149 -0
  7. wireview/auto_broadcast.py +172 -0
  8. wireview/component.py +11 -0
  9. wireview/consumer.py +601 -0
  10. wireview/core/__init__.py +6 -0
  11. wireview/core/component.py +1378 -0
  12. wireview/core/meta.py +615 -0
  13. wireview/core/rendered.py +365 -0
  14. wireview/core/state.py +54 -0
  15. wireview/core/transport.py +132 -0
  16. wireview/debug/__init__.py +96 -0
  17. wireview/debug/sync_detector.py +284 -0
  18. wireview/event_transpiler.py +230 -0
  19. wireview/features/__init__.py +8 -0
  20. wireview/features/presence.py +471 -0
  21. wireview/features/streams.py +49 -0
  22. wireview/features/uploads.py +558 -0
  23. wireview/function_component.py +345 -0
  24. wireview/js.py +489 -0
  25. wireview/live_component.py +245 -0
  26. wireview/log.py +12 -0
  27. wireview/management/__init__.py +0 -0
  28. wireview/management/commands/__init__.py +0 -0
  29. wireview/management/commands/wireview_lsp.py +376 -0
  30. wireview/management/commands/wireview_stubs.py +830 -0
  31. wireview/repository.py +309 -0
  32. wireview/schemas.py +49 -0
  33. wireview/serializer.py +27 -0
  34. wireview/settings.py +46 -0
  35. wireview/slots.py +319 -0
  36. wireview/static/wireview/rendered.mjs +147 -0
  37. wireview/static/wireview/types.d.ts +52 -0
  38. wireview/static/wireview/wireview-boost.js +249 -0
  39. wireview/static/wireview/wireview.js +3124 -0
  40. wireview/static/wireview/wireview.min.js +26 -0
  41. wireview/template_engine.py +332 -0
  42. wireview/templates/wireview_header.html +9 -0
  43. wireview/templatetags/__init__.py +0 -0
  44. wireview/templatetags/wireview.py +831 -0
  45. wireview/testing.py +358 -0
  46. wireview/urls.py +19 -0
  47. wireview/utils.py +198 -0
  48. wireview/views.py +238 -0
@@ -0,0 +1,1199 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-wireview
3
+ Version: 0.2.1
4
+ Summary: Brings LiveView from Phoenix framework into Django
5
+ Project-URL: Homepage, https://github.com/itda-work/django-wireview
6
+ Project-URL: Repository, https://github.com/itda-work/django-wireview
7
+ Author-email: Eddy Ernesto del Valle Pino <eddy@edelvalle.me>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 4.2
13
+ Classifier: Framework :: Django :: 5.0
14
+ Classifier: Framework :: Django :: 5.1
15
+ Classifier: Framework :: Django :: 6.0
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Requires-Python: >=3.12
25
+ Requires-Dist: channels<5,>=4
26
+ Requires-Dist: django>=4.2
27
+ Requires-Dist: pydantic<3,>=2.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: channels-nats; extra == 'dev'
30
+ Requires-Dist: channels-redis; extra == 'dev'
31
+ Requires-Dist: cssselect; extra == 'dev'
32
+ Requires-Dist: daphne; extra == 'dev'
33
+ Requires-Dist: django-hmin; extra == 'dev'
34
+ Requires-Dist: django-stubs; extra == 'dev'
35
+ Requires-Dist: django-stubs-ext; extra == 'dev'
36
+ Requires-Dist: djlint; extra == 'dev'
37
+ Requires-Dist: ipython; extra == 'dev'
38
+ Requires-Dist: lxml; extra == 'dev'
39
+ Requires-Dist: pre-commit; extra == 'dev'
40
+ Requires-Dist: psutil>=7.2.2; extra == 'dev'
41
+ Requires-Dist: pyright; extra == 'dev'
42
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
43
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
44
+ Requires-Dist: pytest-django>=4.5; extra == 'dev'
45
+ Requires-Dist: pytest-playwright>=0.4.0; extra == 'dev'
46
+ Requires-Dist: pytest>=7.0; extra == 'dev'
47
+ Requires-Dist: ruff; extra == 'dev'
48
+ Requires-Dist: twine; extra == 'dev'
49
+ Requires-Dist: uvicorn; extra == 'dev'
50
+ Requires-Dist: websockets; extra == 'dev'
51
+ Requires-Dist: whitenoise; extra == 'dev'
52
+ Description-Content-Type: text/markdown
53
+
54
+ # Wireview - Django를 위한 Phoenix LiveView
55
+
56
+ Wireview는 Django Channels를 사용하여 실시간 서버 렌더링 인터랙티브 UI를 구축할 수 있게 해주는 라이브러리입니다. Phoenix Framework의 LiveView와 유사합니다.
57
+
58
+ ![Wireview 아키텍처 개요](overview.jpg)
59
+
60
+ ## 무엇이 포함되어 있나요?
61
+
62
+ VueJS나 ReactJS를 대체하는 것은 아니지만, Django의 모든 잠재력을 활용하여 인터랙티브한 프론트엔드를 만들 수 있습니다. 모든 것이 서버 사이드에서 렌더링되므로, 첫 번째 요청에서 의미 있는 정보가 포함된 인터페이스가 제공됩니다. Django 템플릿과 ORM의 모든 기능을 컴포넌트에서 직접 사용하고, 이벤트 구독을 통해 실시간으로 인터페이스를 업데이트할 수 있습니다.
63
+
64
+ **주요 기능:**
65
+ - 실시간 업데이트가 가능한 서버 사이드 렌더링 컴포넌트
66
+ - 자동 검증이 포함된 Pydantic 기반 상태 관리
67
+ - Django Channels를 통한 WebSocket 통신
68
+ - 효율적인 대역폭 사용을 위한 HTML diff
69
+ - 자동 UI 업데이트를 위한 모델 구독
70
+ - 대규모 리스트를 효율적으로 처리하는 Streams API
71
+ - 온라인 사용자 및 타이핑 표시를 위한 Presence 추적
72
+ - 진행률 추적이 가능한 파일 업로드
73
+ - Chart.js, Mapbox 등 서드파티 라이브러리 통합을 위한 JavaScript Hooks
74
+
75
+ ## django-reactor 대비 개선 사항
76
+
77
+ Wireview는 [django-reactor](https://github.com/edelvalle/reactor)의 현대적인 진화 버전으로, 다음과 같은 중요한 개선 사항이 있습니다:
78
+
79
+ ### 새로운 기능
80
+
81
+ | 기능 | reactor | wireview | 설명 |
82
+ |------|---------|----------|------|
83
+ | **Streams API** | - | ✅ | `stream()`, `stream_insert()`, `stream_delete()`로 메모리 효율적인 대규모 리스트 처리 |
84
+ | **Presence API** | - | ✅ | `PresenceMixin`, `PresenceTrackerMixin`으로 실시간 사용자 추적 및 타이핑 표시 |
85
+ | **파일 업로드** | - | ✅ | 진행률 추적, 매직 바이트 검증이 포함된 청크 업로드 |
86
+ | **AsyncResult** | - | ✅ | 비동기 작업을 위한 로딩/성공/에러 상태 관리 |
87
+ | **JS 명령어** | - | ✅ | `JS()` 빌더로 Phoenix LiveView.JS 스타일의 클라이언트 사이드 명령어 |
88
+ | **테스트 유틸리티** | - | ✅ | WebSocket 없이 쉽게 컴포넌트 테스트를 위한 `mount()` 유틸리티 |
89
+ | **디버그 도구** | - | ✅ | `wireview.debug`로 브라우저 콘솔 디버깅 |
90
+ | **JavaScript Hooks** | - | ✅ | Chart.js, Mapbox 등 서드파티 JavaScript 라이브러리 통합 |
91
+
92
+ ### 아키텍처 개선
93
+
94
+ | 항목 | reactor | wireview |
95
+ |------|---------|----------|
96
+ | **Pydantic** | v1 (레거시) | v2 (최신) |
97
+ | **DOM Morphing** | morphdom | idiomorph (더 나은 속성 보존) |
98
+ | **Python** | ≥3.9 | ≥3.12 |
99
+ | **Django** | 3.2+ | 4.2, 5.0, 5.1, 6.0 |
100
+ | **모듈 구조** | 플랫 | 체계적 (`core/`, `features/`) |
101
+
102
+ ### 새로운 컴포넌트 메서드
103
+
104
+ ```python
105
+ # 라이프사이클
106
+ async def leaving(self):
107
+ """컴포넌트 연결 해제 시 호출 - 정리 훅"""
108
+
109
+ # UI 제어
110
+ await self.scroll_into_view(element_id, behavior="smooth")
111
+ await self.push_js(JS().set_value("input", ""))
112
+
113
+ # Streams
114
+ await self.stream("items", items)
115
+ await self.stream_insert("items", item, at=0)
116
+ await self.stream_delete("items", item_id)
117
+
118
+ # Presence
119
+ await self.presence_join()
120
+ await self.presence_set_typing(True)
121
+
122
+ # 비동기 로딩
123
+ self.data = await self.assign_async(fetch_data())
124
+
125
+ # JavaScript Hooks
126
+ await self.push_event("update_chart", {"data": [1, 2, 3]})
127
+ ```
128
+
129
+ ### reactor에서 마이그레이션
130
+
131
+ 대부분의 reactor 컴포넌트는 최소한의 변경으로 작동합니다:
132
+
133
+ ```python
134
+ # reactor
135
+ from reactor.component import Component
136
+
137
+ class XCounter(Component):
138
+ _subscriptions = {"counter"}
139
+
140
+ # wireview (동일한 API)
141
+ from wireview.component import Component
142
+
143
+ class XCounter(Component):
144
+ _subscriptions = {"counter"}
145
+ ```
146
+
147
+ 주요 차이점:
148
+ - 패키지 이름: `reactor` → `wireview`
149
+ - 설정 접두사: `REACTOR_*` → `WIREVIEW` dict
150
+ - 템플릿 태그: `{% load reactor %}` → `{% load wireview %}`
151
+
152
+ ## 목차
153
+
154
+ - [django-reactor 대비 개선 사항](#django-reactor-대비-개선-사항)
155
+ - [설치 및 설정](#설치-및-설정)
156
+ - [빠른 시작](#빠른-시작)
157
+ - [컴포넌트 라이프사이클](#컴포넌트-라이프사이클)
158
+ - [이벤트 바인딩](#이벤트-바인딩)
159
+ - [URL 상태 관리](#url-상태-관리)
160
+ - [모델 구독](#모델-구독)
161
+ - [Streams API](#streams-api)
162
+ - [Presence API](#presence-api)
163
+ - [파일 업로드](#파일-업로드)
164
+ - [AsyncResult](#asyncresult와-비동기-작업)
165
+ - [JS 명령어 빌더](#js-명령어-빌더)
166
+ - [JavaScript Hooks](#javascript-hooks)
167
+ - [컴포넌트 API 레퍼런스](#컴포넌트-api-레퍼런스)
168
+ - [템플릿 태그 레퍼런스](#템플릿-태그-레퍼런스)
169
+ - [JavaScript API](#프론트엔드-api)
170
+ - [테스트](#컴포넌트-테스트)
171
+ - [디버그 도구](#디버그-도구)
172
+ - [설정](#설정)
173
+
174
+ ## 설치 및 설정
175
+
176
+ Wireview는 Python ≥3.12과 Django ≥4.2가 필요합니다 (Django 4.2, 5.0, 5.1, 6.0 지원).
177
+
178
+ ```bash
179
+ pip install django-wireview
180
+ ```
181
+
182
+ Wireview는 `django-channels`를 사용합니다. 기본 InMemory 채널 레이어는 프로세스 하나 안에서만 통하므로, 프로세스를 여러 개 띄우면 브로드캐스트가 **오류 없이** 같은 프로세스의 연결에만 닿습니다. 프로덕션에서는 프로세스를 잇는 레이어를 씁니다.
183
+
184
+ - [channels-nats](https://github.com/itda-work/channels-nats) — 이 프로젝트가 목표로 하는 레이어입니다. NATS 서버는 Go 바이너리 하나이고 Linux·macOS·Windows 네이티브 빌드가 있어, Redis 없이 SQLite 단일 서버와 Windows까지 같은 구성으로 갑니다.
185
+ - [channels_redis](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) — Redis가 이미 있다면 이쪽입니다. 실측상 성능은 대등합니다.
186
+
187
+ 배포 구성은 [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md), 두 레이어의 실측 비교는 [docs/design/transport-abstraction.md](./docs/design/transport-abstraction.md) §5-3에 있습니다.
188
+
189
+ Django 애플리케이션보다 먼저 `wireview`와 `channels`를 `INSTALLED_APPS`에 추가하세요:
190
+
191
+ ```python
192
+ INSTALLED_APPS = [
193
+ 'wireview',
194
+ 'channels',
195
+ ...
196
+ ]
197
+
198
+ ASGI_APPLICATION = 'project_name.asgi.application'
199
+ ```
200
+
201
+ `project_name/asgi.py`를 수정하세요:
202
+
203
+ ```python
204
+ import os
205
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project_name.settings')
206
+
207
+ import django
208
+ django.setup()
209
+
210
+ from channels.auth import AuthMiddlewareStack
211
+ from channels.routing import ProtocolTypeRouter, URLRouter
212
+ from django.core.asgi import get_asgi_application
213
+ from wireview.urls import websocket_urlpatterns
214
+
215
+ application = ProtocolTypeRouter({
216
+ 'http': get_asgi_application(),
217
+ 'websocket': AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
218
+ })
219
+ ```
220
+
221
+ 템플릿에 wireview JavaScript를 포함하세요:
222
+
223
+ ```html
224
+ {% load wireview %}
225
+ <!doctype html>
226
+ <html>
227
+ <head>
228
+ {% wireview_header %}
229
+ </head>
230
+ ...
231
+ </html>
232
+ ```
233
+
234
+ ## 빠른 시작
235
+
236
+ `x-counter.html` 템플릿을 생성하세요:
237
+
238
+ ```html
239
+ {% load wireview %}
240
+ <div {% tag_header %}>
241
+ {{ amount }}
242
+ <button {% on 'click' 'inc' %}>+</button>
243
+ <button {% on 'click' 'dec' %}>-</button>
244
+ <button {% on 'click' 'set_to' amount=0 %}>reset</button>
245
+ </div>
246
+ ```
247
+
248
+ `live.py`에 컴포넌트를 생성하세요:
249
+
250
+ ```python
251
+ from wireview.component import Component
252
+
253
+
254
+ class XCounter(Component):
255
+ _template_name = 'x-counter.html'
256
+
257
+ amount: int = 0
258
+
259
+ async def inc(self):
260
+ self.amount += 1
261
+
262
+ async def dec(self):
263
+ self.amount -= 1
264
+
265
+ async def set_to(self, amount: int):
266
+ self.amount = amount
267
+ ```
268
+
269
+ 뷰 템플릿에서 컴포넌트를 렌더링하세요:
270
+
271
+ ```html
272
+ {% load wireview %}
273
+ <!doctype html>
274
+ <html>
275
+ <head>
276
+ {% wireview_header %}
277
+ </head>
278
+ <body>
279
+ {% component 'XCounter' %}
280
+ {% component 'XCounter' amount=100 %}
281
+ </body>
282
+ </html>
283
+ ```
284
+
285
+ ## 컴포넌트 라이프사이클
286
+
287
+ ### 초기화 및 렌더링
288
+
289
+ 컴포넌트는 템플릿에 포함될 때 초기화됩니다:
290
+
291
+ ```html
292
+ {% component 'Component' param1=1 param2=2 %}
293
+ ```
294
+
295
+ 파라미터는 컴포넌트 인스턴스를 반환하는 `Component.new()`에 전달됩니다.
296
+
297
+ ### 조인 (Joins)
298
+
299
+ 컴포넌트가 프론트엔드에 도달하면 WebSocket을 통해 백엔드에 "조인"합니다. 직렬화된 상태가 백엔드로 전송되고, 백엔드는 컴포넌트를 재구성하고 `Component.joined()`를 호출합니다.
300
+
301
+ ```python
302
+ class ChatRoom(Component):
303
+ async def joined(self):
304
+ # 컴포넌트가 WebSocket으로 연결될 때 호출됨
305
+ await self.broadcast(f"room.{self.room_id}", action="joined", user=self.username)
306
+ ```
307
+
308
+ ### 퇴장 (Leaving)
309
+
310
+ 컴포넌트가 파괴되거나 WebSocket 연결이 닫히면 `Component.leaving()`이 호출됩니다. 정리 작업에 사용하세요:
311
+
312
+ ```python
313
+ class ChatRoom(Component):
314
+ async def leaving(self):
315
+ # 컴포넌트 연결이 해제될 때 호출됨
316
+ await self.broadcast(f"room.{self.room_id}", action="left", user=self.username)
317
+ ```
318
+
319
+ ### 사용자 이벤트
320
+
321
+ 조인 후 컴포넌트는 `{% on %}` 템플릿 태그를 통해 사용자 이벤트를 받을 수 있습니다. 이벤트는 백엔드로 전송되고, 핸들러가 실행되며, 컴포넌트가 다시 렌더링됩니다.
322
+
323
+ ### 모델 구독
324
+
325
+ 컴포넌트는 모델 변경을 구독할 수 있습니다. 변경이 발생하면 `Component.mutation()`이 호출됩니다:
326
+
327
+ ```python
328
+ class TodoList(Component):
329
+ _subscriptions = {"todo.item"} # todo 앱의 Item 모델 변경 구독
330
+
331
+ async def mutation(self, channel: str, action: ModelAction, instance):
332
+ # 구독한 모델이 변경될 때 호출됨
333
+ self.items = await self.load_items()
334
+ ```
335
+
336
+ ### 알림
337
+
338
+ 임의의 메시지에는 `broadcast()`와 `notification()`을 사용하세요:
339
+
340
+ ```python
341
+ # 발신자
342
+ await self.broadcast("chat.room.1", message="Hello!", sender=self.username)
343
+
344
+ # 수신자 ("chat.room.1" 구독 중)
345
+ async def notification(self, channel: str, **kwargs):
346
+ message = kwargs.get("message")
347
+ sender = kwargs.get("sender")
348
+ ```
349
+
350
+ ## 이벤트 바인딩
351
+
352
+ ### 기본 문법
353
+
354
+ ```html
355
+ {% on <event.modifiers> <handler> [kwargs] %}
356
+ ```
357
+
358
+ 예제:
359
+
360
+ ```html
361
+ <button {% on "click" "increment" %}>+1</button>
362
+ <button {% on "click" "increment" amount=5 %}>+5</button>
363
+ <button {% on "click.prevent" "submit" %}>제출</button>
364
+ <input {% on "keypress.enter" "search" %}>
365
+ <input {% on "input.debounce.300" "filter" %}>
366
+ ```
367
+
368
+ ### 사용 가능한 수정자
369
+
370
+ | 수정자 | 설명 |
371
+ |--------|------|
372
+ | `prevent` | `event.preventDefault()` 호출 |
373
+ | `stop` | `event.stopPropagation()` 호출 |
374
+ | `ctrl`, `alt`, `shift`, `meta` | 수정 키 필요 |
375
+ | `debounce.<ms>` | 이벤트 디바운스 (예: `debounce.300`) |
376
+ | `throttle.<ms>` | 이벤트 쓰로틀 (예: `throttle.100`) |
377
+ | `enter`, `tab`, `delete`, `backspace`, `space` | 키 별칭 |
378
+ | `up`, `down`, `left`, `right` | 화살표 키 별칭 |
379
+ | `key.<keycode>` | 특정 키 (예: `key.escape`) |
380
+ | `inlinejs` | 핸들러를 리터럴 JavaScript로 처리 |
381
+
382
+ ### 암시적 인자
383
+
384
+ 컴포넌트 내의 폼 입력은 자동으로 인자로 전송됩니다:
385
+
386
+ ```html
387
+ <div {% tag_header %}>
388
+ <input name="query">
389
+ <button {% on "click" "search" %}>검색</button>
390
+ </div>
391
+ ```
392
+
393
+ ```python
394
+ async def search(self, query: str):
395
+ self.results = await self.do_search(query)
396
+ ```
397
+
398
+ ## URL 상태 관리
399
+
400
+ URL 쿼리 문자열에 컴포넌트 상태를 저장하세요:
401
+
402
+ ```python
403
+ class SearchList(Component):
404
+ query: str = ""
405
+
406
+ @classmethod
407
+ def new(cls, wire, **kwargs):
408
+ kwargs.setdefault("query", wire.params.get("query", ""))
409
+ return cls(wire=wire, **kwargs)
410
+
411
+ async def filter_results(self, query: str):
412
+ self.query = query
413
+ self.wire.params["query"] = query # URL 업데이트
414
+ ```
415
+
416
+ 복잡한 값에는 `.json` 접미사를 사용하세요:
417
+
418
+ ```python
419
+ class TreeView(Component):
420
+ @classmethod
421
+ def new(cls, wire, id: str, **kwargs):
422
+ kwargs["expanded"] = id in wire.params.get("expanded.json", [])
423
+ return cls(wire=wire, id=id, **kwargs)
424
+
425
+ async def toggle_expanded(self):
426
+ self.expanded = not self.expanded
427
+ expanded = self.wire.params.setdefault("expanded.json", [])
428
+ if self.expanded:
429
+ expanded.append(self.id)
430
+ elif self.id in expanded:
431
+ expanded.remove(self.id)
432
+ ```
433
+
434
+ ## 모델 구독
435
+
436
+ 자동 UI 업데이트를 위해 Django 모델 변경을 구독하세요:
437
+
438
+ ```python
439
+ class TodoList(Component):
440
+ _subscriptions = {"todo.item"} # {app_label}.{model_name} 형식
441
+
442
+ async def mutation(self, channel: str, action: ModelAction, instance):
443
+ if action == ModelAction.CREATED:
444
+ self.items.append(instance)
445
+ elif action == ModelAction.DELETED:
446
+ self.items = [i for i in self.items if i.id != instance.id]
447
+ ```
448
+
449
+ 설정에서 자동 브로드캐스트를 활성화하세요:
450
+
451
+ ```python
452
+ WIREVIEW = {
453
+ "AUTO_BROADCAST": AutoBroadcast(
454
+ model=True, # 모델 변경 시 브로드캐스트
455
+ model_pk=True, # 채널 이름에 PK 포함
456
+ ),
457
+ }
458
+ ```
459
+
460
+ ## Streams API
461
+
462
+ Streams는 아이템을 개별적으로 렌더링하고 증분 업데이트를 전송하여 대규모 리스트를 메모리 효율적으로 처리합니다.
463
+
464
+ ### 기본 사용법
465
+
466
+ 스트림 컨테이너가 있는 템플릿:
467
+
468
+ ```html
469
+ {% load wireview %}
470
+ <div {% tag_header %}>
471
+ <ul wire-stream="messages">
472
+ {% for message in messages %}
473
+ {% include "chat/message_item.html" %}
474
+ {% endfor %}
475
+ </ul>
476
+ </div>
477
+ ```
478
+
479
+ 아이템 템플릿 (`chat/message_item.html`):
480
+
481
+ ```html
482
+ <li id="messages-{{ message.pk }}">
483
+ <strong>{{ message.sender }}:</strong> {{ message.text }}
484
+ </li>
485
+ ```
486
+
487
+ 컴포넌트:
488
+
489
+ ```python
490
+ class MessageList(Component):
491
+ _template_name = "chat/message_list.html"
492
+ messages: list = []
493
+
494
+ async def joined(self):
495
+ # 스트림으로 초기 로드
496
+ messages = await Message.objects.order_by('-created')[:50]
497
+ await self.stream("messages", reversed(messages))
498
+
499
+ async def add_message(self, text: str):
500
+ message = await Message.objects.acreate(sender=self.user, text=text)
501
+ await self.stream_insert("messages", message, at=-1) # 끝에 추가
502
+ await self.scroll_into_view(f"messages-{message.pk}")
503
+
504
+ async def delete_message(self, message_id: int):
505
+ await Message.objects.filter(id=message_id).adelete()
506
+ await self.stream_delete("messages", message_id)
507
+ ```
508
+
509
+ ### Stream 메서드
510
+
511
+ | 메서드 | 설명 |
512
+ |--------|------|
513
+ | `stream(name, items)` | 스트림 초기화/리셋 |
514
+ | `stream_insert(name, item, at=-1)` | 아이템 삽입 (-1=끝, 0=처음, n=인덱스) |
515
+ | `stream_delete(name, dom_id)` | DOM ID 또는 PK로 아이템 삭제 |
516
+
517
+ ### DOM ID 규칙
518
+
519
+ 기본적으로 DOM ID는 `{stream_name}-{item.pk}` 패턴을 따릅니다. 커스텀 ID 함수:
520
+
521
+ ```python
522
+ await self.stream("items", items, dom_id=lambda item: f"item-{item.uuid}")
523
+ ```
524
+
525
+ ### 커스텀 아이템 템플릿
526
+
527
+ ```python
528
+ await self.stream_insert("messages", message, template="chat/special_message.html")
529
+ ```
530
+
531
+ ## Presence API
532
+
533
+ 온라인 사용자와 타이핑 표시를 실시간으로 추적합니다.
534
+
535
+ ### PresenceMixin (프로듀서)
536
+
537
+ 자신의 프레즌스를 브로드캐스트하는 컴포넌트용:
538
+
539
+ ```python
540
+ from wireview.component import Component
541
+ from wireview.features.presence import PresenceMixin
542
+
543
+
544
+ class ChatInput(PresenceMixin, Component):
545
+ _template_name = "chat/input.html"
546
+ room_id: int
547
+ username: str
548
+
549
+ def _presence_topic(self) -> str:
550
+ return f"room.{self.room_id}"
551
+
552
+ def _presence_user_id(self) -> str:
553
+ return str(self.user_id)
554
+
555
+ def _presence_username(self) -> str:
556
+ return self.username
557
+
558
+ async def joined(self):
559
+ await self.presence_join()
560
+
561
+ async def leaving(self):
562
+ await self.presence_leave()
563
+
564
+ async def on_typing(self):
565
+ await self.presence_set_typing(True) # 3초 후 자동 해제
566
+ ```
567
+
568
+ ### PresenceTrackerMixin (컨슈머)
569
+
570
+ 다른 사용자의 프레즌스를 표시하는 컴포넌트용:
571
+
572
+ ```python
573
+ from wireview.features.presence import PresenceTrackerMixin
574
+
575
+
576
+ class OnlineUsers(PresenceTrackerMixin, Component):
577
+ _template_name = "chat/online_users.html"
578
+ room_id: int
579
+ username: str
580
+
581
+ def _presence_topic(self) -> str:
582
+ return f"room.{self.room_id}"
583
+
584
+ def _presence_my_user_id(self) -> str:
585
+ return str(self.user_id)
586
+
587
+ @property
588
+ def _subscriptions(self):
589
+ return {self._presence_channel()}
590
+
591
+ async def joined(self):
592
+ await self.presence_track_self(username=self.username)
593
+ ```
594
+
595
+ 템플릿:
596
+
597
+ ```html
598
+ {% load wireview %}
599
+ <div {% tag_header %}>
600
+ <h3>온라인 ({{ this.presence_online_count }})</h3>
601
+ <ul>
602
+ {% for user in this.presence_users %}
603
+ <li>
604
+ {{ user.username }}
605
+ {% if user.is_typing %}<span class="typing">입력 중...</span>{% endif %}
606
+ </li>
607
+ {% endfor %}
608
+ </ul>
609
+ </div>
610
+ ```
611
+
612
+ ### Presence 속성
613
+
614
+ | 속성 | 설명 |
615
+ |------|------|
616
+ | `presence_users` | 모든 추적된 사용자 목록 |
617
+ | `presence_online_count` | 온라인 사용자 수 |
618
+ | `presence_typing_users` | 현재 타이핑 중인 사용자 목록 |
619
+
620
+ ### 설정
621
+
622
+ ```python
623
+ from wireview.features.presence import PresenceConfig
624
+
625
+ class MyComponent(PresenceMixin, Component):
626
+ _presence_config = PresenceConfig(
627
+ typing_timeout=3.0, # 타이핑 자동 해제까지 초
628
+ sync_on_join=True, # 조인 시 다른 사용자에게 동기화 요청
629
+ channel_prefix="presence",
630
+ )
631
+ ```
632
+
633
+ ## 파일 업로드
634
+
635
+ 진행률 추적과 검증이 포함된 파일 업로드를 처리합니다.
636
+
637
+ ### 기본 설정
638
+
639
+ ```python
640
+ from wireview.component import Component
641
+ from wireview.features.uploads import UploadConfig
642
+
643
+
644
+ class FileUploader(Component):
645
+ _template_name = "uploader.html"
646
+
647
+ async def joined(self):
648
+ self.allow_upload(UploadConfig(
649
+ name="avatar",
650
+ accept=[".jpg", ".png", ".gif"],
651
+ max_file_size=5 * 1024 * 1024, # 5MB
652
+ max_entries=1,
653
+ ))
654
+
655
+ async def save_avatar(self):
656
+ for upload in self.consume_uploads("avatar"):
657
+ path = await upload.save_to("avatars/", filename=f"{self.user_id}.jpg")
658
+ self.avatar_url = path
659
+ ```
660
+
661
+ 템플릿:
662
+
663
+ ```html
664
+ {% load wireview %}
665
+ <div {% tag_header %}>
666
+ <input type="file" wire-upload="avatar" accept=".jpg,.png,.gif">
667
+
668
+ {% for entry in this.uploads.avatar %}
669
+ <div class="upload-entry">
670
+ {{ entry.client_name }} - {{ entry.progress }}%
671
+ {% if entry.errors %}
672
+ <span class="error">{{ entry.errors|join:", " }}</span>
673
+ {% endif %}
674
+ </div>
675
+ {% endfor %}
676
+
677
+ <button {% on "click" "save_avatar" %}>저장</button>
678
+ </div>
679
+ ```
680
+
681
+ ### UploadConfig 옵션
682
+
683
+ | 옵션 | 기본값 | 설명 |
684
+ |------|--------|------|
685
+ | `name` | 필수 | 업로드 필드 식별자 |
686
+ | `accept` | `[]` | 허용된 확장자 (예: `[".jpg", ".png"]`) |
687
+ | `max_entries` | `1` | 최대 동시 업로드 수 |
688
+ | `max_file_size` | `10MB` | 최대 파일 크기 (바이트) |
689
+ | `chunk_size` | `64KB` | 업로드 청크 크기 |
690
+ | `auto_upload` | `True` | 선택 시 즉시 업로드 시작 |
691
+
692
+ ### ConsumedUpload 메서드
693
+
694
+ | 메서드 | 설명 |
695
+ |--------|------|
696
+ | `read()` | 전체 파일을 메모리로 읽기 |
697
+ | `open(mode="rb")` | 파일 핸들 열기 |
698
+ | `save_to(directory, filename=None)` | Django 스토리지에 저장 |
699
+ | `name` | 원본 파일명 |
700
+ | `size` | 파일 크기 (바이트) |
701
+ | `content_type` | MIME 타입 |
702
+
703
+ ### 보안
704
+
705
+ Wireview는 확장자 위조를 방지하기 위해 저장 전에 파일 시그니처(매직 바이트)를 검증합니다.
706
+
707
+ ## AsyncResult와 비동기 작업
708
+
709
+ 로딩/에러 상태와 함께 비동기 데이터 로딩을 처리합니다:
710
+
711
+ ```python
712
+ from wireview import Component, AsyncResult
713
+
714
+
715
+ class Dashboard(Component):
716
+ _template_name = "dashboard.html"
717
+ stats: AsyncResult = None
718
+
719
+ async def joined(self):
720
+ self.stats = await self.assign_async(self.load_stats())
721
+
722
+ async def load_stats(self):
723
+ return await Stats.objects.aget()
724
+ ```
725
+
726
+ 템플릿:
727
+
728
+ ```html
729
+ {% if stats.loading %}
730
+ <div class="spinner">로딩 중...</div>
731
+ {% elif stats.ok %}
732
+ <div>총계: {{ stats.result.total }}</div>
733
+ {% elif stats.failed %}
734
+ <div class="error">{{ stats.error_message }}</div>
735
+ {% endif %}
736
+ ```
737
+
738
+ ### AsyncResult 속성
739
+
740
+ | 속성 | 설명 |
741
+ |------|------|
742
+ | `loading` | 작업 진행 중이면 True |
743
+ | `ok` | 작업 성공이면 True |
744
+ | `failed` | 작업 실패면 True |
745
+ | `done` | 완료되면 True (성공 또는 실패) |
746
+ | `result` | 결과 값 (성공 시) |
747
+ | `error` | 예외 (실패 시) |
748
+ | `error_message` | 에러의 문자열 표현 |
749
+
750
+ ### AsyncResult 메서드
751
+
752
+ | 메서드 | 설명 |
753
+ |--------|------|
754
+ | `map(func)` | 결과 값 변환 |
755
+ | `get_or(default)` | 결과 또는 기본값 가져오기 |
756
+ | `get_or_raise()` | 결과 가져오기 또는 에러 발생 |
757
+
758
+ ## JS 명령어 빌더
759
+
760
+ 서버 왕복 없이 실행되는 클라이언트 사이드 명령어를 빌드합니다:
761
+
762
+ ```python
763
+ from wireview import JS
764
+
765
+ # 템플릿에서
766
+ <button {% on "click" JS().toggle("#modal") %}>모달 토글</button>
767
+
768
+ # 명령어 체이닝
769
+ <button {% on "click" JS().add_class("#btn", "loading").push("save") %}>
770
+ 저장
771
+ </button>
772
+
773
+ # 트랜지션과 함께
774
+ <div {% on "click" JS().hide(transition=("fade-out", 300)) %}></div>
775
+ ```
776
+
777
+ ### 서버에서 JS 푸시
778
+
779
+ 이벤트 핸들러에서 JS 명령어 전송:
780
+
781
+ ```python
782
+ async def clear_input(self):
783
+ await self.push_js(JS().set_value("input[name=search]", ""))
784
+ ```
785
+
786
+ ### 사용 가능한 명령어
787
+
788
+ **표시:**
789
+ - `show(selector, transition=None, display=None)`
790
+ - `hide(selector, transition=None)`
791
+ - `toggle(selector, show=None, hide=None)`
792
+
793
+ **CSS 클래스:**
794
+ - `add_class(selector, classes, transition=None)`
795
+ - `remove_class(selector, classes, transition=None)`
796
+ - `toggle_class(selector, classes, transition=None)`
797
+
798
+ **속성:**
799
+ - `set_attr(selector, attr, value)`
800
+ - `remove_attr(selector, attr)`
801
+ - `set_value(selector, value)` - 입력 값 설정
802
+
803
+ **포커스:**
804
+ - `focus(selector)`
805
+ - `focus_first(selector, input_only=False)`
806
+
807
+ **트랜지션:**
808
+ - `transition(selector, classes, time=None)`
809
+
810
+ **서버 통신:**
811
+ - `push(event, value=None, target=None)` - 서버로 이벤트 전송
812
+
813
+ **네비게이션:**
814
+ - `navigate(url, replace=False)`
815
+ - `dispatch(event, to=None, detail=None, bubbles=True)`
816
+
817
+ ### 로딩 클래스
818
+
819
+ 서버 요청 중 다음 클래스가 자동으로 추가됩니다:
820
+
821
+ | 클래스 | 설명 |
822
+ |--------|------|
823
+ | `wireview-loading` | 모든 요청 중에 추가 |
824
+ | `wireview-click-loading` | 클릭 이벤트에 추가 |
825
+ | `wireview-submit-loading` | 제출 이벤트에 추가 |
826
+
827
+ ```css
828
+ .wireview-loading {
829
+ opacity: 0.5;
830
+ pointer-events: none;
831
+ }
832
+ ```
833
+
834
+ ## JavaScript Hooks
835
+
836
+ JavaScript Hooks를 사용하면 Chart.js, Mapbox, CodeMirror 등 서드파티 JavaScript 라이브러리를 wireview 컴포넌트와 통합할 수 있습니다. Phoenix LiveView의 Hooks API를 따릅니다.
837
+
838
+ ### Hook 정의
839
+
840
+ ```javascript
841
+ window.wireview.hooks.ChartHook = {
842
+ mounted() {
843
+ // 엘리먼트가 페이지에 추가되면 호출
844
+ const config = JSON.parse(this.el.dataset.config);
845
+ this.chart = new Chart(this.el, config);
846
+ },
847
+
848
+ updated() {
849
+ // DOM 업데이트 후 호출
850
+ this.chart.update();
851
+ },
852
+
853
+ destroyed() {
854
+ // 엘리먼트가 제거되면 호출
855
+ this.chart.destroy();
856
+ },
857
+
858
+ disconnected() {
859
+ // WebSocket 연결이 끊기면 호출
860
+ this.el.classList.add('offline');
861
+ },
862
+
863
+ reconnected() {
864
+ // WebSocket이 재연결되면 호출
865
+ this.el.classList.remove('offline');
866
+ }
867
+ };
868
+ ```
869
+
870
+ ### 템플릿에서 사용
871
+
872
+ ```html
873
+ <div wire-hook="ChartHook" data-config='{"type": "line", "data": {...}}'>
874
+ </div>
875
+ ```
876
+
877
+ ### 서버로 이벤트 전송 (pushEvent)
878
+
879
+ ```javascript
880
+ window.wireview.hooks.InfiniteScroll = {
881
+ mounted() {
882
+ this.page = 1;
883
+ this.observer = new IntersectionObserver(entries => {
884
+ if (entries[0].isIntersecting) {
885
+ this.loadMore();
886
+ }
887
+ });
888
+ this.observer.observe(this.el.querySelector('.sentinel'));
889
+ },
890
+
891
+ loadMore() {
892
+ this.pushEvent("load_more", { page: this.page }, (response) => {
893
+ if (response.hasMore) {
894
+ this.page++;
895
+ } else {
896
+ this.observer.disconnect();
897
+ }
898
+ });
899
+ },
900
+
901
+ destroyed() {
902
+ this.observer.disconnect();
903
+ }
904
+ };
905
+ ```
906
+
907
+ ### 서버에서 이벤트 받기 (handleEvent)
908
+
909
+ ```javascript
910
+ window.wireview.hooks.Notification = {
911
+ mounted() {
912
+ this.handleEvent("show_toast", ({ message, type }) => {
913
+ this.showToast(message, type);
914
+ });
915
+ },
916
+
917
+ showToast(message, type) {
918
+ // 토스트 표시 구현
919
+ }
920
+ };
921
+ ```
922
+
923
+ ### 서버 사이드 핸들러
924
+
925
+ ```python
926
+ class Dashboard(Component):
927
+ _template_name = "dashboard.html"
928
+
929
+ async def handle_hook_event(self, hook_id: str, event: str, payload: dict):
930
+ """JavaScript Hook에서 보낸 이벤트 처리"""
931
+ if event == "load_more":
932
+ items = await self.fetch_items(payload.get("page", 1))
933
+ return {"hasMore": len(items) == 20}
934
+ return None
935
+
936
+ async def update_chart(self, data: list):
937
+ """모든 Hook에 이벤트 전송"""
938
+ await self.push_event("update_data", {"values": data})
939
+ ```
940
+
941
+ ### Hook 라이프사이클
942
+
943
+ | 콜백 | 호출 시점 |
944
+ |------|----------|
945
+ | `mounted()` | 엘리먼트가 조인되고 첫 렌더링 후 |
946
+ | `beforeUpdate()` | DOM morph 전 (동기) |
947
+ | `updated()` | DOM morph 완료 후 |
948
+ | `destroyed()` | 엘리먼트가 DOM에서 제거될 때 |
949
+ | `disconnected()` | WebSocket 연결이 닫힐 때 |
950
+ | `reconnected()` | WebSocket이 재연결될 때 |
951
+
952
+ ### Hook 컨텍스트
953
+
954
+ | 속성/메서드 | 설명 |
955
+ |-------------|------|
956
+ | `this.el` | Hook이 연결된 DOM 엘리먼트 |
957
+ | `this.pushEvent(event, payload, callback)` | 서버로 이벤트 전송 |
958
+ | `this.handleEvent(event, callback)` | 서버 이벤트 핸들러 등록 |
959
+
960
+ 자세한 내용은 [JavaScript Hooks 문서](docs/features/hooks.md)를 참조하세요.
961
+
962
+ ## 컴포넌트 API 레퍼런스
963
+
964
+ ### 클래스 속성
965
+
966
+ | 속성 | 기본값 | 설명 |
967
+ |------|--------|------|
968
+ | `_template_name` | 필수 | 템플릿 경로 |
969
+ | `_exclude_fields` | `{"user", "wire"}` | 직렬화에서 제외할 필드 |
970
+ | `_subscriptions` | `set()` | 구독할 채널 |
971
+
972
+ ### 라이프사이클 메서드
973
+
974
+ | 메서드 | 설명 |
975
+ |--------|------|
976
+ | `new(cls, wire, **kwargs)` | 인스턴스 생성 클래스 메서드 |
977
+ | `joined()` | 컴포넌트가 WebSocket으로 연결될 때 호출 |
978
+ | `leaving()` | 컴포넌트 연결이 해제될 때 호출 |
979
+ | `mutation(channel, action, instance)` | 모델 변경 시 호출 |
980
+ | `notification(channel, **kwargs)` | 브로드캐스트 메시지 시 호출 |
981
+ | `handle_hook_event(hook_id, event, payload)` | Hook 이벤트 수신 시 호출 |
982
+
983
+ ### 렌더 제어
984
+
985
+ | 메서드 | 설명 |
986
+ |--------|------|
987
+ | `skip_render()` | 다음 렌더 사이클 건너뛰기 |
988
+ | `send_render()` | 즉시 렌더 강제 |
989
+ | `force_render()` | 다시 렌더링 표시 |
990
+ | `freeze()` | 모든 향후 렌더 방지 |
991
+
992
+ ### 액션
993
+
994
+ | 메서드 | 설명 |
995
+ |--------|------|
996
+ | `destroy()` | 인터페이스에서 컴포넌트 제거 |
997
+ | `focus_on(selector)` | 요소에 포커스 |
998
+ | `scroll_into_view(element_id, behavior="auto", block="start", inline="nearest")` | 요소를 뷰로 스크롤 |
999
+ | `push_js(js)` | 클라이언트에서 JS 명령어 실행 |
1000
+ | `dom(action, id, component_or_template, **kwargs)` | DOM 조작 |
1001
+ | `deffer(func, *args, **kwargs)` | 함수 실행 지연 |
1002
+ | `push_event(event, payload, hook_id=None)` | Hook에 이벤트 전송 |
1003
+
1004
+ ### 브로드캐스팅
1005
+
1006
+ | 메서드 | 설명 |
1007
+ |--------|------|
1008
+ | `broadcast(channel, **kwargs)` | 채널로 메시지 전송 (`joined()`에서 큐잉) |
1009
+ | `abroadcast(channel, **kwargs)` | 즉시 메시지 전송 (비동기) |
1010
+
1011
+ ### 네비게이션
1012
+
1013
+ | 메서드 | 설명 |
1014
+ |--------|------|
1015
+ | `wire.redirect_to(url, **kwargs)` | 네비게이트하고 새 페이지 가져오기 |
1016
+ | `wire.replace_to(url, **kwargs)` | 현재 URL 교체 |
1017
+ | `wire.push_to(url, **kwargs)` | 가져오기 없이 URL 푸시 |
1018
+
1019
+ ### Streams
1020
+
1021
+ | 메서드 | 설명 |
1022
+ |--------|------|
1023
+ | `stream(name, items, template=None, dom_id=None)` | 스트림 초기화/리셋 |
1024
+ | `stream_insert(name, item, at=-1, template=None, dom_id=None)` | 아이템 삽입 |
1025
+ | `stream_delete(name, dom_id)` | 아이템 삭제 |
1026
+
1027
+ ### Uploads
1028
+
1029
+ | 메서드 | 설명 |
1030
+ |--------|------|
1031
+ | `allow_upload(config)` | 업로드 설정 등록 |
1032
+ | `consume_uploads(name)` | 완료된 업로드 가져오기 |
1033
+ | `cancel_upload(name, ref)` | 업로드 취소 |
1034
+
1035
+ ## 템플릿 태그 레퍼런스
1036
+
1037
+ ```html
1038
+ {% load wireview %}
1039
+ ```
1040
+
1041
+ | 태그 | 설명 |
1042
+ |------|------|
1043
+ | `{% wireview_header %}` | 필요한 JavaScript 포함 (~10KB 압축) |
1044
+ | `{% component 'Name' kwarg=value %}` | 컴포넌트 렌더링 |
1045
+ | `{% on 'event.modifiers' 'handler' kwargs %}` | 이벤트 핸들러 바인딩 |
1046
+ | `{% tag_header %}` | 루트 요소에 컴포넌트 속성 추가 |
1047
+ | `{% cond {'hidden': is_hidden} %}` | 조건부 속성 |
1048
+ | `{% class {'active': is_active} %}` | 조건부 CSS 클래스 |
1049
+
1050
+ ## 프론트엔드 API
1051
+
1052
+ ```javascript
1053
+ // 컴포넌트에 이벤트 전송
1054
+ wireview.send(element, 'handler_name', {arg1: value1})
1055
+
1056
+ // 디바운스/쓰로틀
1057
+ wireview.debounce(300)(fn)
1058
+ wireview.throttle(100)(fn)
1059
+
1060
+ // JS 명령어 실행
1061
+ wireview.exec(element, commands)
1062
+
1063
+ // Hook 정의
1064
+ wireview.hooks.MyHook = {
1065
+ mounted() { /* ... */ },
1066
+ updated() { /* ... */ },
1067
+ destroyed() { /* ... */ }
1068
+ }
1069
+
1070
+ // 디버그 유틸리티
1071
+ wireview.debug.enable()
1072
+ wireview.debug.disable()
1073
+ wireview.debug.status()
1074
+ ```
1075
+
1076
+ ## 컴포넌트 테스트
1077
+
1078
+ WebSocket 없이 컴포넌트 테스트:
1079
+
1080
+ ```python
1081
+ import pytest
1082
+ from wireview.testing import mount
1083
+
1084
+
1085
+ @pytest.mark.asyncio
1086
+ async def test_counter_increment():
1087
+ view = await mount(Counter, count=0)
1088
+ await view.call("increment", amount=5)
1089
+ assert view.component.count == 5
1090
+ assert len(view.sent_messages) > 0
1091
+
1092
+
1093
+ @pytest.mark.asyncio
1094
+ async def test_redirect():
1095
+ view = await mount(MyComponent)
1096
+ await view.call("do_redirect", url="/dashboard")
1097
+ assert view.redirected_to == "/dashboard"
1098
+ assert view.is_frozen
1099
+ ```
1100
+
1101
+ ### 테스트 API
1102
+
1103
+ | 메서드/속성 | 설명 |
1104
+ |-------------|------|
1105
+ | `mount(ComponentClass, **kwargs)` | 테스트용 컴포넌트 마운트 |
1106
+ | `view.component` | 컴포넌트 인스턴스 접근 |
1107
+ | `view.call(handler, **kwargs)` | 이벤트 핸들러 호출 |
1108
+ | `view.sent_messages` | 전송될 메시지들 |
1109
+ | `view.redirected_to` | 리다이렉트 URL (있는 경우) |
1110
+ | `view.is_frozen` | 컴포넌트 동결 여부 |
1111
+ | `view.clear_messages()` | 전송 메시지 초기화 |
1112
+
1113
+ ## 디버그 도구
1114
+
1115
+ ```javascript
1116
+ // 디버그 로깅 활성화
1117
+ wireview.debug.enable()
1118
+
1119
+ // 디버그 로깅 비활성화
1120
+ wireview.debug.disable()
1121
+
1122
+ // 네트워크 지연 시뮬레이션
1123
+ wireview.debug.latency(500) // 500ms 지연
1124
+
1125
+ // 연결 상태 표시
1126
+ wireview.debug.status()
1127
+
1128
+ // 모든 컴포넌트 나열
1129
+ wireview.debug.components()
1130
+
1131
+ // 특정 컴포넌트 가져오기
1132
+ wireview.debug.component("rx-123")
1133
+ ```
1134
+
1135
+ ## 설정
1136
+
1137
+ ```python
1138
+ from wireview.schemas import AutoBroadcast
1139
+
1140
+ WIREVIEW = {
1141
+ "TRANSPILER_CACHE_SIZE": 1024, # 이벤트 핸들러 캐시 크기
1142
+ "USE_HTML_DIFF": True, # HTML diff 활성화
1143
+ "USE_HMIN": False, # django-hmin 압축 사용
1144
+ "BOOST_PAGES": False, # 클라이언트 사이드 네비게이션 활성화
1145
+ "AUTO_BROADCAST": AutoBroadcast(
1146
+ model=False, # 모델 변경 시 브로드캐스트
1147
+ model_pk=False, # 채널에 PK 포함
1148
+ related=False, # 관련 모델 변경 브로드캐스트
1149
+ m2m=False, # M2M 변경 브로드캐스트
1150
+ senders=set(), # 자동 브로드캐스트할 모델
1151
+ ),
1152
+ }
1153
+ ```
1154
+
1155
+ ## 성능 최적화
1156
+
1157
+ 최적의 성능을 위해:
1158
+
1159
+ - **uvloop 사용**: Uvicorn에서 `--loop uvloop` 옵션으로 더 나은 비동기 성능 달성
1160
+ - **개발 중 전환 추적**: `DEBUG_SYNC_TRANSITIONS=True`로 중첩 async/sync 전환 감지
1161
+ - **컴포넌트에서 `asend_to()` 선호**: async 컨텍스트에서는 `send_to()` 대신 `asend_to()` 사용
1162
+
1163
+ ```python
1164
+ WIREVIEW = {
1165
+ "DEBUG_SYNC_TRANSITIONS": True, # 개발 환경에서만
1166
+ "USE_HTML_DIFF": True,
1167
+ "USE_HMIN": True, # django-hmin 설치 필요
1168
+ }
1169
+ ```
1170
+
1171
+ 자세한 내용은 [성능 가이드](docs/PERFORMANCE.md)를 참조하세요.
1172
+
1173
+ ## 문서
1174
+
1175
+ - [아키텍처](docs/ARCHITECTURE.md) - 내부 설계 및 패턴
1176
+ - [배포 가이드](docs/DEPLOYMENT.md) - 프로덕션 배포 설정
1177
+ - [성능 가이드](docs/PERFORMANCE.md) - 성능 최적화 팁
1178
+ - [튜토리얼](docs/tutorials/) - 단계별 가이드
1179
+ - [로드맵](docs/ROADMAP.md) - 향후 개발 계획
1180
+
1181
+ ## 개발 및 기여
1182
+
1183
+ ```bash
1184
+ git clone git@github.com:itda-work/django-wireview.git
1185
+ cd django-wireview
1186
+ make install
1187
+ make test
1188
+ ```
1189
+
1190
+ 테스트 서버 실행:
1191
+
1192
+ ```bash
1193
+ cd tests
1194
+ python manage.py runserver
1195
+ ```
1196
+
1197
+ ## 라이선스
1198
+
1199
+ MIT 라이선스 - 자세한 내용은 [LICENSE](LICENSE)를 참조하세요.