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 ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ djact — Inertia.js-like bridge between Django and React.
3
+ """
4
+
5
+ default_app_config = "djact.apps.DjactConfig"
6
+
7
+ from djact.render import djact_render # noqa: E402, F401
8
+
9
+ __all__ = ["djact_render"]
10
+ __version__ = "1.0.0"
djact/apps.py ADDED
@@ -0,0 +1,10 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjactConfig(AppConfig):
5
+ name = "djact"
6
+ verbose_name = "Djact"
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+
9
+ def ready(self):
10
+ pass # Reserved for signal registration or startup hooks
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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