djact 1.0.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.
- djact/__init__.py +10 -0
- djact/apps.py +10 -0
- djact/middleware.py +46 -0
- djact/py.typed +1 -0
- djact/render.py +99 -0
- djact/static/djact/app.js +591 -0
- djact/templates/djact/djact.html +83 -0
- djact/utils.py +23 -0
- djact-1.0.0.dist-info/METADATA +384 -0
- djact-1.0.0.dist-info/RECORD +13 -0
- djact-1.0.0.dist-info/WHEEL +5 -0
- djact-1.0.0.dist-info/licenses/LICENSE +21 -0
- djact-1.0.0.dist-info/top_level.txt +1 -0
djact/__init__.py
ADDED
djact/apps.py
ADDED
djact/middleware.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from django.http import HttpResponseRedirect
|
|
2
|
+
from djact.utils import is_djact_request
|
|
3
|
+
|
|
4
|
+
class Djact:
|
|
5
|
+
"""Helper attached to request.djact to manage shared data."""
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self._shared_data = {}
|
|
8
|
+
|
|
9
|
+
def share(self, key, value):
|
|
10
|
+
"""Share data with all Djact responses in this request cycle."""
|
|
11
|
+
self._shared_data[key] = value
|
|
12
|
+
|
|
13
|
+
def get_shared(self):
|
|
14
|
+
return self._shared_data
|
|
15
|
+
|
|
16
|
+
class DjactMiddleware:
|
|
17
|
+
"""Annotate incoming requests and provide a response-level extension hook.
|
|
18
|
+
|
|
19
|
+
Attaches ``request.is_djact`` (``bool``) and ``request.djact`` (shared data helper).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, get_response):
|
|
23
|
+
self.get_response = get_response
|
|
24
|
+
|
|
25
|
+
def __call__(self, request):
|
|
26
|
+
# --- request phase ---
|
|
27
|
+
request.is_djact = is_djact_request(request)
|
|
28
|
+
request.djact = Djact()
|
|
29
|
+
|
|
30
|
+
response = self.get_response(request)
|
|
31
|
+
|
|
32
|
+
# Handle redirects for Djact requests
|
|
33
|
+
if request.is_djact and isinstance(response, HttpResponseRedirect):
|
|
34
|
+
# Tell the JS engine where we are going
|
|
35
|
+
response['X-Djact-Location'] = response['Location']
|
|
36
|
+
|
|
37
|
+
# --- response phase ---
|
|
38
|
+
return self._process_response(request, response)
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# Extension hooks
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def _process_response(self, request, response):
|
|
45
|
+
"""Override in a subclass to add response-level Djact logic."""
|
|
46
|
+
return response
|
djact/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# py.typed — PEP 561 marker: this package ships inline type annotations.
|
djact/render.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
djact.render
|
|
3
|
+
~~~~~~~~~~~~
|
|
4
|
+
Core rendering helper — the primary public API of djact.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from django.http import JsonResponse
|
|
10
|
+
from django.shortcuts import render as django_render
|
|
11
|
+
|
|
12
|
+
from djact.utils import is_djact_request
|
|
13
|
+
|
|
14
|
+
DJACT_HEADER = "X-Djact"
|
|
15
|
+
|
|
16
|
+
# Characters that must be escaped when embedding JSON in an HTML attribute.
|
|
17
|
+
# json.dumps handles < > & by default; we also ensure ' is escaped so the
|
|
18
|
+
# value is safe inside single-quoted attributes.
|
|
19
|
+
_JSON_ESCAPE_TABLE = str.maketrans(
|
|
20
|
+
{
|
|
21
|
+
"<": r"\u003c",
|
|
22
|
+
">": r"\u003e",
|
|
23
|
+
"&": r"\u0026",
|
|
24
|
+
"'": r"\u0027",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _safe_json(data: dict) -> str:
|
|
30
|
+
"""Serialise *data* to a JSON string that is safe to embed in HTML attributes.
|
|
31
|
+
|
|
32
|
+
Uses ``json.dumps`` with ``ensure_ascii=False`` (so unicode characters are
|
|
33
|
+
kept readable) and then escapes the five characters that are dangerous
|
|
34
|
+
inside HTML attribute values: ``< > & ' "``. The result can be placed
|
|
35
|
+
directly in a ``data-page="..."`` or ``data-page='...'`` attribute using
|
|
36
|
+
Django's ``|safe`` template filter without risk of XSS.
|
|
37
|
+
"""
|
|
38
|
+
raw = json.dumps(data, ensure_ascii=False)
|
|
39
|
+
return raw.translate(_JSON_ESCAPE_TABLE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def djact_render(
|
|
43
|
+
request,
|
|
44
|
+
component: str,
|
|
45
|
+
props: dict | None = None,
|
|
46
|
+
*,
|
|
47
|
+
status: int = 200,
|
|
48
|
+
extra_context: dict | None = None,
|
|
49
|
+
):
|
|
50
|
+
"""Render a React component via the Djact bridge.
|
|
51
|
+
|
|
52
|
+
On a full (first-visit) request returns a complete HTML page.
|
|
53
|
+
On a Djact XHR navigation request (``X-Djact`` header) returns a
|
|
54
|
+
lightweight JSON payload so the client router can swap components
|
|
55
|
+
without a full reload.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
request: The current Django ``HttpRequest``.
|
|
59
|
+
component: React component identifier string (e.g. ``"Home"``,
|
|
60
|
+
``"Admin/Users"``, or ``"Auth/Login"``). This string is
|
|
61
|
+
passed verbatim to your frontend resolver.
|
|
62
|
+
props: JSON-serialisable dict of props. Defaults to ``{}``.
|
|
63
|
+
status: HTTP status code. Defaults to ``200``.
|
|
64
|
+
extra_context: Additional context variables merged into the template
|
|
65
|
+
context (full-page renders only). Use to pass a custom
|
|
66
|
+
``title`` or any other template variable.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
``JsonResponse`` — XHR navigation (``X-Djact`` header present).
|
|
70
|
+
``TemplateResponse`` — Full-page first load.
|
|
71
|
+
"""
|
|
72
|
+
if props is None:
|
|
73
|
+
props = {}
|
|
74
|
+
|
|
75
|
+
# Merge shared data from middleware if available
|
|
76
|
+
final_props = {}
|
|
77
|
+
if hasattr(request, "djact"):
|
|
78
|
+
final_props.update(request.djact.get_shared())
|
|
79
|
+
final_props.update(props)
|
|
80
|
+
|
|
81
|
+
page_payload: dict = {
|
|
82
|
+
"component": component,
|
|
83
|
+
"props": final_props,
|
|
84
|
+
"url": request.get_full_path(),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if is_djact_request(request):
|
|
88
|
+
return JsonResponse(page_payload, status=status)
|
|
89
|
+
|
|
90
|
+
context = {"page": _safe_json(page_payload)}
|
|
91
|
+
if extra_context:
|
|
92
|
+
context.update(extra_context)
|
|
93
|
+
|
|
94
|
+
return django_render(
|
|
95
|
+
request,
|
|
96
|
+
"djact/djact.html",
|
|
97
|
+
context,
|
|
98
|
+
status=status,
|
|
99
|
+
)
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* djact/app.js — v1.1.0
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Djact client-side engine.
|
|
5
|
+
*
|
|
6
|
+
* Public API
|
|
7
|
+
* ──────────
|
|
8
|
+
* createDjactApp({ resolve, onStart?, onFinish?, onError? })
|
|
9
|
+
* djactVisit(url, options?)
|
|
10
|
+
* Link
|
|
11
|
+
* useDjactPage()
|
|
12
|
+
* useDjactLoading()
|
|
13
|
+
*
|
|
14
|
+
* Peer dependencies (supplied by host project)
|
|
15
|
+
* ─────────────────────────────────────────────
|
|
16
|
+
* react ≥ 18
|
|
17
|
+
* react-dom ≥ 18
|
|
18
|
+
*
|
|
19
|
+
* Component naming
|
|
20
|
+
* ────────────────
|
|
21
|
+
* Djact does not enforce a naming convention or folder structure.
|
|
22
|
+
* The string you pass to `djact_render` in Django is passed verbatim
|
|
23
|
+
* to your `resolve` function here.
|
|
24
|
+
*
|
|
25
|
+
* Examples:
|
|
26
|
+
* Django: djact_render(request, "Home")
|
|
27
|
+
* JS: resolve("Home")
|
|
28
|
+
*
|
|
29
|
+
* Django: djact_render(request, "Admin/Settings")
|
|
30
|
+
* JS: resolve("Admin/Settings")
|
|
31
|
+
*
|
|
32
|
+
* You decide how to map these strings to files in your project.
|
|
33
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import React, {
|
|
37
|
+
useState,
|
|
38
|
+
useEffect,
|
|
39
|
+
useRef,
|
|
40
|
+
useCallback,
|
|
41
|
+
memo,
|
|
42
|
+
} from "react";
|
|
43
|
+
import { createRoot } from "react-dom/client";
|
|
44
|
+
|
|
45
|
+
// ─── Internal singleton state ─────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Single React root — created once, reused for every navigation. */
|
|
48
|
+
let _root = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Current page: { Component, componentName, props, url }
|
|
52
|
+
* `componentName` is kept so popstate can skip a redundant re-resolve.
|
|
53
|
+
*/
|
|
54
|
+
let _page = null;
|
|
55
|
+
|
|
56
|
+
/** User-supplied resolver: (name: string) → Component | Promise<Component> */
|
|
57
|
+
let _resolve = null;
|
|
58
|
+
|
|
59
|
+
/** Registered page-change listeners (used by DjactApp + useDjactPage). */
|
|
60
|
+
const _pageListeners = new Set();
|
|
61
|
+
|
|
62
|
+
/** Registered loading-state listeners (used by useDjactLoading). */
|
|
63
|
+
const _loadingListeners = new Set();
|
|
64
|
+
|
|
65
|
+
/** Guard against concurrent navigations to the same URL. */
|
|
66
|
+
let _inflight = null;
|
|
67
|
+
|
|
68
|
+
/** Resolved-component LRU cache — avoids re-importing the same module. */
|
|
69
|
+
const _componentCache = new Map();
|
|
70
|
+
const _CACHE_MAX = 50;
|
|
71
|
+
|
|
72
|
+
// ─── CSRF ─────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the CSRF token from <meta name="csrf-token"> (injected by djact.html)
|
|
76
|
+
* with a fallback to the `csrftoken` cookie (standard Django behaviour).
|
|
77
|
+
*
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function getCsrfToken() {
|
|
81
|
+
// 1. Meta tag (preferred — always present on Djact pages).
|
|
82
|
+
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
83
|
+
if (meta) return meta.getAttribute("content") ?? "";
|
|
84
|
+
|
|
85
|
+
// 2. Cookie fallback (useful when the template is overridden).
|
|
86
|
+
const match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
|
|
87
|
+
return match ? decodeURIComponent(match[1]) : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Loading state ────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function _setLoading(value) {
|
|
93
|
+
_loadingListeners.forEach((fn) => fn(value));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Component resolution + cache ─────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a component name → React component type.
|
|
100
|
+
*
|
|
101
|
+
* Results are cached (LRU eviction at _CACHE_MAX entries) so repeated
|
|
102
|
+
* navigations to the same page do not re-execute the dynamic import.
|
|
103
|
+
*
|
|
104
|
+
* Supports multi-app names like "library/Dashboard" or "student/Profile" —
|
|
105
|
+
* the name is passed verbatim to the host project's `resolve` function.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} name
|
|
108
|
+
* @returns {Promise<React.ComponentType>}
|
|
109
|
+
*/
|
|
110
|
+
async function resolveComponent(name) {
|
|
111
|
+
if (!_resolve) {
|
|
112
|
+
throw new Error("[Djact] createDjactApp() has not been called yet.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (_componentCache.has(name)) {
|
|
116
|
+
return _componentCache.get(name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mod = await Promise.resolve(_resolve(name));
|
|
120
|
+
|
|
121
|
+
// Support `export default` and re-exported default patterns.
|
|
122
|
+
const Component =
|
|
123
|
+
mod && typeof mod === "object" && "default" in mod ? mod.default : mod;
|
|
124
|
+
|
|
125
|
+
if (typeof Component !== "function") {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[Djact] resolve("${name}") did not return a React component. ` +
|
|
128
|
+
`Got: ${typeof Component}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Cache with simple LRU eviction.
|
|
133
|
+
if (_componentCache.size >= _CACHE_MAX) {
|
|
134
|
+
_componentCache.delete(_componentCache.keys().next().value);
|
|
135
|
+
}
|
|
136
|
+
_componentCache.set(name, Component);
|
|
137
|
+
|
|
138
|
+
return Component;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Internal page state ──────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Update internal page state and notify all subscribed listeners.
|
|
145
|
+
* Skips the update (no-op) if the component name, props, and URL are
|
|
146
|
+
* all identical to the current page — preventing redundant re-renders.
|
|
147
|
+
*
|
|
148
|
+
* @param {React.ComponentType} Component
|
|
149
|
+
* @param {string} componentName The raw name string from Django.
|
|
150
|
+
* @param {object} props
|
|
151
|
+
* @param {string} url
|
|
152
|
+
*/
|
|
153
|
+
function _setPage(Component, componentName, props, url) {
|
|
154
|
+
// Shallow-equality guard — skip if nothing actually changed.
|
|
155
|
+
if (
|
|
156
|
+
_page &&
|
|
157
|
+
_page.componentName === componentName &&
|
|
158
|
+
_page.url === url &&
|
|
159
|
+
_shallowEqual(_page.props, props)
|
|
160
|
+
) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_page = { Component, componentName, props, url };
|
|
165
|
+
_pageListeners.forEach((fn) => fn(_page));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Shallow-equal two plain objects. */
|
|
169
|
+
function _shallowEqual(a, b) {
|
|
170
|
+
if (a === b) return true;
|
|
171
|
+
if (!a || !b) return false;
|
|
172
|
+
const keysA = Object.keys(a);
|
|
173
|
+
if (keysA.length !== Object.keys(b).length) return false;
|
|
174
|
+
return keysA.every((k) => a[k] === b[k]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Navigation ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* djactVisit(url, options?)
|
|
181
|
+
*
|
|
182
|
+
* Perform a client-side Djact navigation.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} url
|
|
185
|
+
* @param {object} [options]
|
|
186
|
+
* @param {boolean} [options.replace=false] Replace history entry instead of push.
|
|
187
|
+
* @param {object} [options.data={}] Extra props merged onto server props.
|
|
188
|
+
* @param {boolean} [options.forceReload=false] Bypass in-flight dedup guard.
|
|
189
|
+
* @returns {Promise<void>}
|
|
190
|
+
*/
|
|
191
|
+
export async function djactVisit(
|
|
192
|
+
url,
|
|
193
|
+
{ replace = false, data = {}, forceReload = false } = {}
|
|
194
|
+
) {
|
|
195
|
+
// ── Deduplicate concurrent requests to the same URL ──────────────────────
|
|
196
|
+
if (!forceReload && _inflight === url) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
_inflight = url;
|
|
200
|
+
_setLoading(true);
|
|
201
|
+
|
|
202
|
+
let payload;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(url, {
|
|
206
|
+
headers: {
|
|
207
|
+
"X-Djact": "true",
|
|
208
|
+
"X-CSRFToken": getCsrfToken(),
|
|
209
|
+
Accept: "application/json",
|
|
210
|
+
},
|
|
211
|
+
credentials: "same-origin",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── Graceful HTTP error handling ─────────────────────────────────────
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
const status = response.status;
|
|
217
|
+
console.error(
|
|
218
|
+
`[Djact] Navigation to "${url}" failed — HTTP ${status}.`
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (status === 404 || status >= 500) {
|
|
222
|
+
// Hard reload so Django's own error pages are shown.
|
|
223
|
+
window.location.href = url;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// For 3xx / 4xx (e.g. 401 login redirect), follow via hard nav.
|
|
228
|
+
const location = response.headers.get("Location");
|
|
229
|
+
window.location.href = location || url;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
payload = await response.json();
|
|
234
|
+
} catch (err) {
|
|
235
|
+
// ── Network-level error (offline, CORS, timeout) ─────────────────────
|
|
236
|
+
console.error("[Djact] Network error during navigation:", err);
|
|
237
|
+
window.location.href = url;
|
|
238
|
+
return;
|
|
239
|
+
} finally {
|
|
240
|
+
_inflight = null;
|
|
241
|
+
_setLoading(false);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { component: name, props = {}, url: resolvedUrl } = payload;
|
|
245
|
+
const mergedProps = { ...props, ...data };
|
|
246
|
+
|
|
247
|
+
let Component;
|
|
248
|
+
try {
|
|
249
|
+
Component = await resolveComponent(name);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error(`[Djact] Could not resolve component "${name}":`, err);
|
|
252
|
+
// Render an inline error boundary rather than going blank.
|
|
253
|
+
_setPage(_NotFound, name, { componentName: name }, resolvedUrl || url);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const historyUrl = resolvedUrl || url;
|
|
258
|
+
|
|
259
|
+
if (replace) {
|
|
260
|
+
window.history.replaceState(
|
|
261
|
+
{ djact: true, component: name, props: mergedProps },
|
|
262
|
+
"",
|
|
263
|
+
historyUrl
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
window.history.pushState(
|
|
267
|
+
{ djact: true, component: name, props: mergedProps },
|
|
268
|
+
"",
|
|
269
|
+
historyUrl
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_setPage(Component, name, mergedProps, historyUrl);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Built-in fallback components ─────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Shown when a component name cannot be resolved.
|
|
280
|
+
* Styled minimally so it is visible but unobtrusive.
|
|
281
|
+
*/
|
|
282
|
+
function _NotFound({ componentName }) {
|
|
283
|
+
return React.createElement(
|
|
284
|
+
"div",
|
|
285
|
+
{
|
|
286
|
+
style: {
|
|
287
|
+
padding: "2rem",
|
|
288
|
+
fontFamily: "monospace",
|
|
289
|
+
color: "#c0392b",
|
|
290
|
+
background: "#fff5f5",
|
|
291
|
+
border: "1px solid #f5c6cb",
|
|
292
|
+
borderRadius: "4px",
|
|
293
|
+
margin: "1rem",
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
React.createElement("strong", null, "[Djact] Component not found: "),
|
|
297
|
+
componentName
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─── Root React component ─────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* DjactApp — mounted once, drives all page transitions.
|
|
305
|
+
*
|
|
306
|
+
* Uses `memo` so React can bail out of reconciliation when the parent
|
|
307
|
+
* (createRoot) triggers a synthetic re-render.
|
|
308
|
+
*/
|
|
309
|
+
const DjactApp = memo(function DjactApp({ initialPage }) {
|
|
310
|
+
const [page, setPage] = useState(initialPage);
|
|
311
|
+
// Track previous component name to log transitions in dev.
|
|
312
|
+
const prevNameRef = useRef(initialPage?.componentName);
|
|
313
|
+
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
_pageListeners.add(setPage);
|
|
316
|
+
return () => _pageListeners.delete(setPage);
|
|
317
|
+
}, []);
|
|
318
|
+
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (
|
|
321
|
+
process.env.NODE_ENV !== "production" &&
|
|
322
|
+
page?.componentName !== prevNameRef.current
|
|
323
|
+
) {
|
|
324
|
+
console.debug(
|
|
325
|
+
`[Djact] ${prevNameRef.current} → ${page?.componentName} (${page?.url})`
|
|
326
|
+
);
|
|
327
|
+
prevNameRef.current = page?.componentName;
|
|
328
|
+
}
|
|
329
|
+
}, [page]);
|
|
330
|
+
|
|
331
|
+
if (!page?.Component) return null;
|
|
332
|
+
|
|
333
|
+
const { Component, props } = page;
|
|
334
|
+
return React.createElement(Component, props);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* createDjactApp({ resolve, onStart?, onFinish?, onError? })
|
|
341
|
+
*
|
|
342
|
+
* Bootstrap the Djact client. Call exactly once at your entry point.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} options
|
|
345
|
+
* @param {Function} options.resolve Maps component name → component.
|
|
346
|
+
* @param {Function} [options.onStart] Called before every navigation.
|
|
347
|
+
* @param {Function} [options.onFinish] Called after every navigation.
|
|
348
|
+
* @param {Function} [options.onError] Called on unrecoverable errors.
|
|
349
|
+
* @returns {Promise<void>}
|
|
350
|
+
*/
|
|
351
|
+
export async function createDjactApp({
|
|
352
|
+
resolve,
|
|
353
|
+
onStart,
|
|
354
|
+
onFinish,
|
|
355
|
+
onError,
|
|
356
|
+
} = {}) {
|
|
357
|
+
if (!resolve || typeof resolve !== "function") {
|
|
358
|
+
throw new Error("[Djact] createDjactApp() requires a `resolve` function.");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_resolve = resolve;
|
|
362
|
+
|
|
363
|
+
// Wire up optional lifecycle hooks to the loading state broadcaster.
|
|
364
|
+
if (onStart || onFinish) {
|
|
365
|
+
_loadingListeners.add((isLoading) => {
|
|
366
|
+
if (isLoading && onStart) onStart();
|
|
367
|
+
if (!isLoading && onFinish) onFinish();
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Read initial page data from the DOM ──────────────────────────────────
|
|
372
|
+
const container = document.getElementById("app");
|
|
373
|
+
if (!container) {
|
|
374
|
+
const msg = '[Djact] Mount point <div id="app"> not found.';
|
|
375
|
+
console.error(msg);
|
|
376
|
+
if (onError) onError(new Error(msg));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const rawPageData = container.dataset.page;
|
|
381
|
+
if (!rawPageData) {
|
|
382
|
+
const msg = "[Djact] data-page attribute is missing or empty on #app.";
|
|
383
|
+
console.error(msg);
|
|
384
|
+
if (onError) onError(new Error(msg));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let pageData;
|
|
389
|
+
try {
|
|
390
|
+
pageData = JSON.parse(rawPageData);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.error("[Djact] Failed to parse data-page JSON:", err);
|
|
393
|
+
if (onError) onError(err);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const {
|
|
398
|
+
component: name,
|
|
399
|
+
props = {},
|
|
400
|
+
url = window.location.pathname,
|
|
401
|
+
} = pageData;
|
|
402
|
+
|
|
403
|
+
// ── Resolve initial component ────────────────────────────────────────────
|
|
404
|
+
let Component;
|
|
405
|
+
try {
|
|
406
|
+
Component = await resolveComponent(name);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.error(`[Djact] Could not resolve initial component "${name}":`, err);
|
|
409
|
+
if (onError) onError(err);
|
|
410
|
+
Component = _NotFound;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Stamp initial history state ──────────────────────────────────────────
|
|
414
|
+
window.history.replaceState(
|
|
415
|
+
{ djact: true, component: name, props },
|
|
416
|
+
"",
|
|
417
|
+
url
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
_page = { Component, componentName: name, props, url };
|
|
421
|
+
|
|
422
|
+
// ── Mount React 18 root ──────────────────────────────────────────────────
|
|
423
|
+
_root = createRoot(container);
|
|
424
|
+
_root.render(React.createElement(DjactApp, { initialPage: _page }));
|
|
425
|
+
|
|
426
|
+
// ── popstate (browser back / forward) ───────────────────────────────────
|
|
427
|
+
window.addEventListener("popstate", async (event) => {
|
|
428
|
+
const state = event.state;
|
|
429
|
+
|
|
430
|
+
if (state?.djact) {
|
|
431
|
+
// History state already carries component + props — no network round-trip.
|
|
432
|
+
let PopComponent;
|
|
433
|
+
try {
|
|
434
|
+
PopComponent = await resolveComponent(state.component);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
console.error(
|
|
437
|
+
`[Djact] popstate: could not resolve "${state.component}":`,
|
|
438
|
+
err
|
|
439
|
+
);
|
|
440
|
+
PopComponent = _NotFound;
|
|
441
|
+
}
|
|
442
|
+
_setPage(
|
|
443
|
+
PopComponent,
|
|
444
|
+
state.component,
|
|
445
|
+
state.props,
|
|
446
|
+
window.location.pathname + window.location.search
|
|
447
|
+
);
|
|
448
|
+
} else {
|
|
449
|
+
// Non-Djact history entry — do a fresh XHR fetch.
|
|
450
|
+
await djactVisit(window.location.href, { replace: true });
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Link component ───────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* <Link> — SPA-style anchor with active-class, replace-mode, and prefetch.
|
|
459
|
+
*
|
|
460
|
+
* @param {object} props
|
|
461
|
+
* @param {string} props.href Destination URL.
|
|
462
|
+
* @param {React.ReactNode} props.children Link content.
|
|
463
|
+
* @param {string} [props.className] Base CSS class.
|
|
464
|
+
* @param {string} [props.activeClassName] Class added when href matches current URL.
|
|
465
|
+
* @param {boolean} [props.replace] Replace history instead of push.
|
|
466
|
+
* @param {boolean} [props.prefetch] Prefetch on hover (resolves + caches component).
|
|
467
|
+
* @param {Function} [props.onClick] Extra click handler (runs before navigation).
|
|
468
|
+
* @param {object} [props.data] Extra props merged into server props.
|
|
469
|
+
* @param {*} [props.*] Forwarded to the underlying <a>.
|
|
470
|
+
*
|
|
471
|
+
* @returns {React.ReactElement}
|
|
472
|
+
*/
|
|
473
|
+
export function Link({
|
|
474
|
+
href,
|
|
475
|
+
children,
|
|
476
|
+
className,
|
|
477
|
+
activeClassName = "active",
|
|
478
|
+
replace = false,
|
|
479
|
+
prefetch = false,
|
|
480
|
+
onClick,
|
|
481
|
+
data = {},
|
|
482
|
+
...rest
|
|
483
|
+
}) {
|
|
484
|
+
// ── Active state ─────────────────────────────────────────────────────────
|
|
485
|
+
const [page, setPage] = useState(_page);
|
|
486
|
+
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
_pageListeners.add(setPage);
|
|
489
|
+
return () => _pageListeners.delete(setPage);
|
|
490
|
+
}, []);
|
|
491
|
+
|
|
492
|
+
const isActive =
|
|
493
|
+
page?.url === href ||
|
|
494
|
+
(href !== "/" && page?.url?.startsWith(href));
|
|
495
|
+
|
|
496
|
+
const computedClass = [
|
|
497
|
+
className,
|
|
498
|
+
isActive && activeClassName ? activeClassName : "",
|
|
499
|
+
]
|
|
500
|
+
.filter(Boolean)
|
|
501
|
+
.join(" ") || undefined;
|
|
502
|
+
|
|
503
|
+
// ── Prefetch on hover ────────────────────────────────────────────────────
|
|
504
|
+
const handleMouseEnter = useCallback(() => {
|
|
505
|
+
if (!prefetch) return;
|
|
506
|
+
// Fire-and-forget: warms up the component cache only.
|
|
507
|
+
fetch(href, {
|
|
508
|
+
headers: { "X-Djact": "true", Accept: "application/json" },
|
|
509
|
+
credentials: "same-origin",
|
|
510
|
+
})
|
|
511
|
+
.then((r) => r.json())
|
|
512
|
+
.then((payload) => resolveComponent(payload.component))
|
|
513
|
+
.catch(() => {}); // Prefetch failures are silent.
|
|
514
|
+
}, [href, prefetch]);
|
|
515
|
+
|
|
516
|
+
// ── Click handler ────────────────────────────────────────────────────────
|
|
517
|
+
const handleClick = useCallback(
|
|
518
|
+
(event) => {
|
|
519
|
+
// Let modifier-key clicks (Cmd/Ctrl/Shift/Alt) behave natively.
|
|
520
|
+
if (
|
|
521
|
+
event.metaKey ||
|
|
522
|
+
event.ctrlKey ||
|
|
523
|
+
event.shiftKey ||
|
|
524
|
+
event.altKey
|
|
525
|
+
) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (onClick) {
|
|
530
|
+
onClick(event);
|
|
531
|
+
if (event.defaultPrevented) return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
event.preventDefault();
|
|
535
|
+
djactVisit(href, { replace, data });
|
|
536
|
+
},
|
|
537
|
+
[href, replace, data, onClick]
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
return React.createElement(
|
|
541
|
+
"a",
|
|
542
|
+
{
|
|
543
|
+
href,
|
|
544
|
+
className: computedClass,
|
|
545
|
+
onClick: handleClick,
|
|
546
|
+
onMouseEnter: prefetch ? handleMouseEnter : undefined,
|
|
547
|
+
"aria-current": isActive ? "page" : undefined,
|
|
548
|
+
...rest,
|
|
549
|
+
},
|
|
550
|
+
children
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* useDjactPage()
|
|
558
|
+
*
|
|
559
|
+
* Returns the current page object: { Component, componentName, props, url }
|
|
560
|
+
*
|
|
561
|
+
* @returns {{ Component: React.ComponentType, componentName: string, props: object, url: string }}
|
|
562
|
+
*/
|
|
563
|
+
export function useDjactPage() {
|
|
564
|
+
const [page, setPage] = useState(_page);
|
|
565
|
+
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
_pageListeners.add(setPage);
|
|
568
|
+
return () => _pageListeners.delete(setPage);
|
|
569
|
+
}, []);
|
|
570
|
+
|
|
571
|
+
return page;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* useDjactLoading()
|
|
576
|
+
*
|
|
577
|
+
* Returns `true` while a navigation fetch is in progress, `false` otherwise.
|
|
578
|
+
* Use to show a progress bar or spinner.
|
|
579
|
+
*
|
|
580
|
+
* @returns {boolean}
|
|
581
|
+
*/
|
|
582
|
+
export function useDjactLoading() {
|
|
583
|
+
const [loading, setLoading] = useState(false);
|
|
584
|
+
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
_loadingListeners.add(setLoading);
|
|
587
|
+
return () => _loadingListeners.delete(setLoading);
|
|
588
|
+
}, []);
|
|
589
|
+
|
|
590
|
+
return loading;
|
|
591
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="{{ LANGUAGE_CODE|default:'en' }}">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
|
|
7
|
+
{# ── Title — override via context variable `title` or the block ─────── #}
|
|
8
|
+
{% block title %}
|
|
9
|
+
<title>{% if title %}{{ title|escape }}{% else %}App{% endif %}</title>
|
|
10
|
+
{% endblock %}
|
|
11
|
+
|
|
12
|
+
{% load static %}
|
|
13
|
+
|
|
14
|
+
{#
|
|
15
|
+
CSRF token — read by the JS engine (getCsrfToken()) for all
|
|
16
|
+
fetch/XHR requests. The value is rendered by Django's csrf_token
|
|
17
|
+
tag which is already HTML-safe.
|
|
18
|
+
#}
|
|
19
|
+
<meta name="csrf-token" content="{{ csrf_token }}" />
|
|
20
|
+
|
|
21
|
+
{#
|
|
22
|
+
Application meta — useful for version-checking and debugging.
|
|
23
|
+
The djact:version value can be set via the `djact_version` context
|
|
24
|
+
variable if you inject it through a context processor.
|
|
25
|
+
#}
|
|
26
|
+
{% if djact_version %}
|
|
27
|
+
<meta name="djact:version" content="{{ djact_version }}" />
|
|
28
|
+
{% endif %}
|
|
29
|
+
|
|
30
|
+
{# ── Preconnect hints — add your CDN / font origins here ───────────── #}
|
|
31
|
+
{% block preconnect %}{% endblock %}
|
|
32
|
+
|
|
33
|
+
{# ── Stylesheets / extra head content ───────────────────────────────── #}
|
|
34
|
+
{% block head %}{% endblock %}
|
|
35
|
+
</head>
|
|
36
|
+
|
|
37
|
+
<body class="{% block body_class %}{% endblock %}">
|
|
38
|
+
|
|
39
|
+
{#
|
|
40
|
+
── Loading indicator ────────────────────────────────────────────────
|
|
41
|
+
This element is shown while the initial JS bundle is loading.
|
|
42
|
+
Djact hides it automatically once the React root is mounted.
|
|
43
|
+
Style it however you like; the id="djact-loading" is the hook.
|
|
44
|
+
#}
|
|
45
|
+
{% block loading %}
|
|
46
|
+
<div id="djact-loading" aria-hidden="true" style="display:none"></div>
|
|
47
|
+
{% endblock %}
|
|
48
|
+
|
|
49
|
+
{#
|
|
50
|
+
── React mount point ────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
`data-page` carries the full JSON payload:
|
|
53
|
+
{
|
|
54
|
+
"component": "library/Dashboard",
|
|
55
|
+
"props": { ... },
|
|
56
|
+
"url": "/current/path/"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
The value is produced by djact.render._safe_json() which escapes
|
|
60
|
+
< > & ' " so the string is safe to embed in any HTML attribute
|
|
61
|
+
context. We still add |safe to prevent Django's auto-escape from
|
|
62
|
+
double-encoding the already-escaped output.
|
|
63
|
+
#}
|
|
64
|
+
<div id="app" data-page="{{ page|safe }}"></div>
|
|
65
|
+
|
|
66
|
+
{#
|
|
67
|
+
── Djact client bundle ──────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
Loaded as type="module":
|
|
70
|
+
• Deferred (runs after DOM is parsed — no need for DOMContentLoaded).
|
|
71
|
+
• Enables top-level await and native ESM imports.
|
|
72
|
+
• Scoped — no global namespace pollution.
|
|
73
|
+
|
|
74
|
+
In production, replace this with your bundled output and use
|
|
75
|
+
{% static 'djact/app.min.js' %} or your asset manifest hash.
|
|
76
|
+
#}
|
|
77
|
+
<script type="module" src="{% static 'djact/app.js' %}"></script>
|
|
78
|
+
|
|
79
|
+
{# ── Extra scripts / analytics injected by child templates ──────────── #}
|
|
80
|
+
{% block scripts %}{% endblock %}
|
|
81
|
+
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
djact/utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
djact.utils
|
|
3
|
+
~~~~~~~~~~~
|
|
4
|
+
Internal utility helpers shared across the package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Django normalises HTTP headers: uppercase, hyphens → underscores, HTTP_ prefix.
|
|
8
|
+
_DJACT_META_KEY = "HTTP_X_DJACT"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_djact_request(request) -> bool:
|
|
12
|
+
"""Return True when the request carries the X-Djact header."""
|
|
13
|
+
return _DJACT_META_KEY in request.META
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_csrf_token(request) -> str | None:
|
|
17
|
+
"""Return the CSRF token for the current request, or None on failure."""
|
|
18
|
+
try:
|
|
19
|
+
from django.middleware.csrf import get_token
|
|
20
|
+
|
|
21
|
+
return get_token(request)
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: djact
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Inertia.js-like bridge between Django and React — server-driven SPA without a REST API.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2024 djact contributors
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://github.com/yourusername/djact
|
|
28
|
+
Project-URL: Repository, https://github.com/yourusername/djact
|
|
29
|
+
Project-URL: Bug Tracker, https://github.com/yourusername/djact/issues
|
|
30
|
+
Keywords: django,react,inertia,spa,frontend,bridge
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Environment :: Web Environment
|
|
33
|
+
Classifier: Framework :: Django
|
|
34
|
+
Classifier: Framework :: Django :: 4.0
|
|
35
|
+
Classifier: Framework :: Django :: 4.1
|
|
36
|
+
Classifier: Framework :: Django :: 4.2
|
|
37
|
+
Classifier: Framework :: Django :: 5.0
|
|
38
|
+
Classifier: Framework :: Django :: 5.1
|
|
39
|
+
Classifier: Intended Audience :: Developers
|
|
40
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
41
|
+
Classifier: Operating System :: OS Independent
|
|
42
|
+
Classifier: Programming Language :: Python :: 3
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
46
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
47
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
48
|
+
Requires-Python: >=3.10
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
License-File: LICENSE
|
|
51
|
+
Requires-Dist: django>=4.0
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
# djact ⚛️🐍
|
|
55
|
+
|
|
56
|
+
Welcome to **djact**! This is a complete, step-by-step guide to building your first project using Django and React without the headache of APIs.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 1. What is Djact?
|
|
61
|
+
|
|
62
|
+
Think of **djact** as a bridge.
|
|
63
|
+
|
|
64
|
+
- **Django** handles the "Brain": It manages your database, users, and page logic.
|
|
65
|
+
- **React** handles the "Beauty": It renders the user interface (UI) and makes it feel fast.
|
|
66
|
+
- **No REST API Needed**: You don't need to build complex endpoints. You just pass data from Django to React like you would with a normal template.
|
|
67
|
+
- **SPA Feel**: Your app feels like a Single Page Application (fast and fluid) because Djact only swaps the content of the page when you click a link.
|
|
68
|
+
|
|
69
|
+
## 2. Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install djact
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 3. Create your Django Project
|
|
76
|
+
|
|
77
|
+
Let's start a fresh project from scratch.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Create the project folder
|
|
81
|
+
django-admin startproject myproject
|
|
82
|
+
|
|
83
|
+
# Go inside the project
|
|
84
|
+
cd myproject
|
|
85
|
+
|
|
86
|
+
# Create an app called "core"
|
|
87
|
+
python manage.py startapp core
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**What just happened?**
|
|
91
|
+
You now have a folder named `myproject` containing your project settings and an app named `core` where we will write our code.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 4. Django Setup
|
|
96
|
+
|
|
97
|
+
Open `myproject/settings.py` and make these changes:
|
|
98
|
+
|
|
99
|
+
### Add to `INSTALLED_APPS`
|
|
100
|
+
Tell Django to use `djact` and your new `core` app.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
INSTALLED_APPS = [
|
|
104
|
+
...
|
|
105
|
+
"djact",
|
|
106
|
+
"core",
|
|
107
|
+
]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Add Middleware
|
|
111
|
+
Middleware is code that runs on every request. This allows Djact to detect when a user is navigating.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
MIDDLEWARE = [
|
|
115
|
+
...
|
|
116
|
+
"djact.middleware.DjactMiddleware",
|
|
117
|
+
]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 5. Create a View
|
|
123
|
+
|
|
124
|
+
In Django, a "View" is the function that handles a specific URL.
|
|
125
|
+
|
|
126
|
+
Open `core/views.py` and paste this:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from djact import djact_render
|
|
130
|
+
|
|
131
|
+
def home(request):
|
|
132
|
+
return djact_render(request, "Home", {
|
|
133
|
+
"name": "Ankur"
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Explanation:**
|
|
138
|
+
We are telling Django to render a React component named **"Home"** and pass it a prop named `name` with the value `"Ankur"`.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 6. Create URLs
|
|
143
|
+
|
|
144
|
+
We need to tell Django which URL should trigger our view.
|
|
145
|
+
|
|
146
|
+
### Step 1: Create `core/urls.py`
|
|
147
|
+
Create a new file at `core/urls.py` and add:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from django.urls import path
|
|
151
|
+
from .views import home
|
|
152
|
+
|
|
153
|
+
urlpatterns = [
|
|
154
|
+
path("", home, name="home"),
|
|
155
|
+
]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Step 2: Update `myproject/urls.py`
|
|
159
|
+
Update your project's main URL file to include the one we just made:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from django.urls import path, include
|
|
163
|
+
|
|
164
|
+
urlpatterns = [
|
|
165
|
+
path("", include("core.urls")),
|
|
166
|
+
]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 7. Template Setup
|
|
172
|
+
|
|
173
|
+
Djact needs one basic HTML file to "mount" React.
|
|
174
|
+
|
|
175
|
+
1. Create a folder named `templates` in your project root.
|
|
176
|
+
2. Inside `templates`, create a file named `djact.html`.
|
|
177
|
+
|
|
178
|
+
### File: `templates/djact.html`
|
|
179
|
+
|
|
180
|
+
```html
|
|
181
|
+
<!DOCTYPE html>
|
|
182
|
+
<html lang="en">
|
|
183
|
+
<head>
|
|
184
|
+
<meta charset="UTF-8">
|
|
185
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
186
|
+
<meta name="csrf-token" content="{{ csrf_token }}">
|
|
187
|
+
<title>My Djact App</title>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
<!-- React will mount inside this div -->
|
|
191
|
+
<div id="app" data-page='{{ page|safe }}'></div>
|
|
192
|
+
|
|
193
|
+
<!-- This is your bundled React JavaScript -->
|
|
194
|
+
<script type="module" src="/static/dist/main.js"></script>
|
|
195
|
+
</body>
|
|
196
|
+
</html>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Why is this needed?**
|
|
200
|
+
This is the "shell". Django loads this file once, and then React takes over the `div#app`. The `data-page` attribute is how Django "sends" the initial props to React.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 8. React Setup
|
|
205
|
+
|
|
206
|
+
Now let's set up the frontend using Vite.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
# In your project root, run:
|
|
210
|
+
npm create vite@latest frontend -- --template react
|
|
211
|
+
|
|
212
|
+
# Go into the frontend folder
|
|
213
|
+
cd frontend
|
|
214
|
+
|
|
215
|
+
# Install dependencies
|
|
216
|
+
npm install
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 9. The Bridge: `main.jsx`
|
|
222
|
+
|
|
223
|
+
This is the most important file in the frontend. It connects Django to React.
|
|
224
|
+
|
|
225
|
+
Open `frontend/src/main.jsx` and replace everything with:
|
|
226
|
+
|
|
227
|
+
```jsx
|
|
228
|
+
import { createDjactApp } from "djact/static/djact/app.js";
|
|
229
|
+
|
|
230
|
+
createDjactApp({
|
|
231
|
+
resolve: (name) => import(`./Pages/${name}.jsx`),
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**What is happening here?**
|
|
236
|
+
- `createDjactApp`: This is the Djact engine. It reads the `data-page` from the HTML we created in Step 7.
|
|
237
|
+
- `resolve`: This function tells Djact where to find your React files. When Django says "Render Home", Djact looks for `./Pages/Home.jsx`.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## 10. Create React Pages
|
|
242
|
+
|
|
243
|
+
Create a folder named `Pages` inside `frontend/src/`. Then create your first page.
|
|
244
|
+
|
|
245
|
+
### File: `frontend/src/Pages/Home.jsx`
|
|
246
|
+
|
|
247
|
+
```jsx
|
|
248
|
+
export default function Home({ name }) {
|
|
249
|
+
return (
|
|
250
|
+
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
251
|
+
<h1>Hello {name}!</h1>
|
|
252
|
+
<p>Welcome to your Djact application.</p>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Note:** The `name` prop comes directly from the Django view we wrote in Step 5!
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 11. Build React
|
|
263
|
+
|
|
264
|
+
Before Django can see your React code, you must build it.
|
|
265
|
+
|
|
266
|
+
In your `frontend` folder, open `vite.config.js` and set the build output to your Django static folder:
|
|
267
|
+
|
|
268
|
+
```javascript
|
|
269
|
+
// vite.config.js
|
|
270
|
+
import { defineConfig } from 'vite'
|
|
271
|
+
import react from '@vitejs/plugin-react'
|
|
272
|
+
|
|
273
|
+
export default defineConfig({
|
|
274
|
+
plugins: [react()],
|
|
275
|
+
build: {
|
|
276
|
+
outDir: '../static/dist', // Build directly into Django's static folder
|
|
277
|
+
rollupOptions: {
|
|
278
|
+
output: {
|
|
279
|
+
entryFileNames: `[name].js`,
|
|
280
|
+
chunkFileNames: `[name].js`,
|
|
281
|
+
assetFileNames: `[name].[ext]`,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Now run the build:
|
|
289
|
+
```bash
|
|
290
|
+
npm run build
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 12. Run the Project
|
|
296
|
+
|
|
297
|
+
Go back to your project root (where `manage.py` is) and start the server:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
python manage.py runserver
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Open your browser and visit: **http://127.0.0.1:8000/**
|
|
304
|
+
|
|
305
|
+
You should see **"Hello Ankur!"**
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## 13. Navigation (Moving between pages)
|
|
310
|
+
|
|
311
|
+
To move between pages without a full reload, use the `<Link>` component.
|
|
312
|
+
|
|
313
|
+
```jsx
|
|
314
|
+
import { Link } from "djact/static/djact/app.js";
|
|
315
|
+
|
|
316
|
+
export default function Home({ name }) {
|
|
317
|
+
return (
|
|
318
|
+
<div>
|
|
319
|
+
<h1>Hello {name}</h1>
|
|
320
|
+
<Link href="/about">Go to About Page</Link>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**How it works:**
|
|
327
|
+
When you click the Link, Djact tells Django "Hey, I need the About page data". Django sends back the props, and React swaps the component instantly.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## 14. Full Project Structure
|
|
332
|
+
|
|
333
|
+
This is what your project should look like:
|
|
334
|
+
|
|
335
|
+
```text
|
|
336
|
+
myproject/
|
|
337
|
+
├── core/
|
|
338
|
+
│ ├── urls.py
|
|
339
|
+
│ └── views.py
|
|
340
|
+
├── myproject/
|
|
341
|
+
│ ├── settings.py
|
|
342
|
+
│ └── urls.py
|
|
343
|
+
├── templates/
|
|
344
|
+
│ └── djact.html
|
|
345
|
+
├── static/
|
|
346
|
+
│ └── dist/ (Generated by npm run build)
|
|
347
|
+
├── frontend/
|
|
348
|
+
│ ├── src/
|
|
349
|
+
│ │ ├── Pages/
|
|
350
|
+
│ │ │ └── Home.jsx
|
|
351
|
+
│ │ └── main.jsx
|
|
352
|
+
│ └── vite.config.js
|
|
353
|
+
└── manage.py
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## 15. Common Questions
|
|
359
|
+
|
|
360
|
+
**Q: Where should I write my React code?**
|
|
361
|
+
A: Inside `frontend/src/Pages/`. Each file should match the name you use in `djact_render`.
|
|
362
|
+
|
|
363
|
+
**Q: Do I need React Router?**
|
|
364
|
+
A: **No.** Django handles all the routes in `urls.py`.
|
|
365
|
+
|
|
366
|
+
**Q: Why do I need to run `npm run build`?**
|
|
367
|
+
A: Browsers cannot read `.jsx` files directly. The build step converts them into a single `.js` file that the browser understands.
|
|
368
|
+
|
|
369
|
+
**Q: Can I use multiple Django apps?**
|
|
370
|
+
A: Yes! You can organize your pages however you like. Just make sure your `resolve` function in `main.jsx` can find them.
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## 16. Common Mistakes to Avoid
|
|
375
|
+
|
|
376
|
+
1. **Forgetting to Build**: If you change your React code, you must run `npm run build` (or run Vite in watch mode) for Django to see the changes.
|
|
377
|
+
2. **Missing Middleware**: If you get a "Page not found" error or the link reloads the whole page, check if `DjactMiddleware` is in `settings.py`.
|
|
378
|
+
3. **Wrong Static Path**: Make sure the `<script>` tag in `djact.html` matches where Vite is building your files.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## 📄 License
|
|
383
|
+
|
|
384
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
djact/__init__.py,sha256=a2h2gxoVptAtTpoCVQAmrq9rH-t2VL2Ifw5YssvkgGA,223
|
|
2
|
+
djact/apps.py,sha256=J1mamxu8DenwwRKC77m6iBGzkn_ZMnUHH-QYu7Rt9_Q,257
|
|
3
|
+
djact/middleware.py,sha256=vM2r6n9Nwz0TjFeqAQNEx6wVkIWYAA1_xq3HKUvcE3I,1554
|
|
4
|
+
djact/py.typed,sha256=PFDWdul5xFEGkC3SiF1iIpFSbukOZFXpF9fr3hGb67M,75
|
|
5
|
+
djact/render.py,sha256=b4scQLNAWldm2l0aljwVVk_UUuNEo27I5EUJRgYEzDI,3151
|
|
6
|
+
djact/utils.py,sha256=NebLuFZQ0OqFH7W9JD2i21porn5N62PQBW8kGi7aNYQ,614
|
|
7
|
+
djact/static/djact/app.js,sha256=N2Yqyo04_V-E6ze8oonZxFLi2OY5cjvtMYT0HKp_4po,19927
|
|
8
|
+
djact/templates/djact/djact.html,sha256=ecFZIU1Kuj1o3zH_0d_74UjQ4o0nAM2_8yQFcL2Cjw8,3392
|
|
9
|
+
djact-1.0.0.dist-info/licenses/LICENSE,sha256=osNGSMuNQvuZnWRBvRk4zmOJpGedlyTmKmx7miWkS5c,1075
|
|
10
|
+
djact-1.0.0.dist-info/METADATA,sha256=9yc-le3uyaM-AqTgFd1j4VNOp3kZRq2qMnchhaMl3LA,10330
|
|
11
|
+
djact-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
djact-1.0.0.dist-info/top_level.txt,sha256=R8hM28vvmCfuRcaHtLOoNj5uz15XQ985ocSElI2CQV0,6
|
|
13
|
+
djact-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 djact contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
djact
|