Added
- Streaming Markdown
{% djust_markdown %}(v0.7.0) — server-side Markdown renderer built onpulldown-cmark 0.12with three safety guarantees wired in at the crate level: raw HTML in the source is escaped (Options::ENABLE_HTMLis never set; because pulldown-cmark 0.12 still emitsEvent::Html/Event::InlineHtmlwhen that flag is off,sanitise_eventre-routes those events toEvent::Textso the writer escapes them),javascript:/vbscript:/data:URL schemes in link/image destinations are rewritten to#(case-insensitive, leading-whitespace tolerant), and inputs larger than 10 MiB (per-call input cap, not a concurrency limiter) are returned as an escaped<pre class="djust-md-toobig">block without invoking the parser. A provisional-line splitter renders a partially-typed trailing line as escaped text inside<p class="djust-md-provisional">, eliminating mid-token flicker for streaming LLM output. Exposed three ways: the{% djust_markdown expr [kwargs] %}tag (registered via the existing Rust tag-handler registry), the Python helperdjust.render_markdown(src, **opts)returning aSafeString, and the PyO3 functiondjust._rust.render_markdown. Kwargs:provisional,tables,strikethrough,task_lists. Note on deviation from plan:autolinkswas dropped from the public surface — pulldown-cmark 0.12 does not expose aGFM_AUTOLINK/ENABLE_AUTOLINKoptions flag, so plain-text URLs stay as text unless wrapped in explicit[text](url)syntax. Will be reconsidered when the upstream parser is bumped. Covered by 24 Rust tests (crates/djust_templates/src/markdown.rs, including regression cases forvbscript:,data:, mixed-caseJavaScript:, leading-whitespace URLs,<iframe>escaping, image-src neutralisation, and the 10 MiB cap) and 14 Python tests (python/djust/tests/test_markdown.py+tests/unit/test_markdown_tag.py), plus 3 A090 system-check tests — 41 total (24 Rust + 14 Python/tag + 3 A090). Demo at/demos/markdown-stream/; full write-up in docs/website/guides/streaming-markdown.md. - Admin widgets & bulk-action progress (v0.7.0) — two additions to
djust.admin_extclose the most-requested gaps in the alternative reactive admin:DjustModelAdmin.change_form_widgets/change_list_widgetsclass attributes accept any list ofLiveViewsubclasses; each is embedded via{% live_render %}on the matching admin page. Permission filtering honourspermission_requiredon the widget class. See docs/website/guides/admin-widgets.md.@admin_action_with_progress(indjust.admin_ext.progress) turns anyDjustModelAdminaction into a background daemon thread and redirects the user to aBulkActionProgressWidgetpage at<admin>/djust-progress/<job_id>/. The page polls the job every 500 ms, re-renders the progress bar / message / log, and wires a Cancel button that atomically flipsdoneandcancelled. Queryset is eagerly pinned to PKs before the thread starts (no lazy-eval foot-guns). Cancellation is cooperative — clicking Cancel flipsprogress.cancelled = True; the action body must periodically checkif progress.cancelled: returnto actually stop (Python cannot safely interrupt a running thread mid-statement).- Server-side permission enforcement —
@admin_action_with_progress(permissions=[...])stampsallowed_permissionson the wrapped action;ModelListView.run_actionnow callsrequest.user.has_perms(allowed)before dispatching the action and raisesPermissionDeniedif the user lacks any declared perm. Closes the gap wherehas_*_permissionreturns True for any staff user. - Bounded server state:
_JOBSis LRU-capped at_MAX_JOBS = 500(oldest entries evicted on insert once the cap is reached), andJob.message/Job.errorare individually truncated to_MAX_MESSAGE_CHARS = 4096on eachprogress.update(...)call.Job.erroris a generic user-facing string ("Action failed — see server logs for details"); the raw exception text lives only on the server-sideJob._error_rawattribute and is always logged at ERROR level vialogger.exception(logger namedjust.admin_ext.progress). - New setting:
DJUST_ASGI_WORKERS(default1) — declares the number of ASGI workers in the deployment. Gates the A073 system check (fires only whenDJUST_ASGI_WORKERS > 1) so single-worker development stays silent. - Defense-in-depth allowlist:
DJUST_LIVE_RENDER_ALLOWED_MODULES(optional) restricts the dotted-path module prefixes that{% live_render %}will resolve — any widget slot path outside the allowlist raisesTemplateSyntaxErrorat render time. - Two new system checks:
djust.A072(warning) fires if a non-LiveViewclass is registered in a widget slot;djust.A073(info, gated onDJUST_ASGI_WORKERS > 1) fires at startup if any admin site hosts a@admin_action_with_progress-decorated action, noting the v0.7.0 single-worker_JOBSlimitation and pointing at the v0.7.1 channel-layer follow-up. - 25 new tests:
python/djust/tests/test_bulk_progress.py(12) +python/djust/tests/test_admin_widgets_per_page.py(13); +A072/A073 check tests inpython/tests/test_checks.py.
{% dj_activity %}+ActivityMixin(v0.7.0) — React 19.2<Activity>parity: pre-rendered hidden regions of a LiveView that preserve their local DOM state (form inputs, scroll, transient JS) across show/hide cycles. The new block tag{% dj_activity "name" visible=expr eager=expr %}...{% enddj_activity %}emits a wrapper<div>carryingdata-djust-activity,data-djust-visible, and — when not visible — the HTMLhiddenattribute plusaria-hidden="true". The body is rendered unconditionally in every pass so local state isn't lost.ActivityMixin(composed intoLiveViewAFTERStickyChildRegistry, BEFOREView) provides the server-side API:set_activity_visible(name, visible),is_activity_visible(name), declarativeeager_activities: frozensetclass attr, and an internal FIFO deferred-event queue (cap 100, overridable viaactivity_event_queue_cap) drained by the WebSocket consumer after everyhandle_event/handle_infodispatch. Client runtime (python/djust/static/djust/src/49-activity.js) exposeswindow.djust.activityVisible(name)and dispatches a bubblingdjust:activity-shownCustomEvent when a panel flips hidden → visible. The event-dispatch gate in11-event-handler.jsdrops events whose trigger sits inside a hidden non-eager activity client-side (stamping_activityon all other events for server-side deferral). The VDOM patcher in12-vdom-patch.jsskips subtree patches targeting nodes inside a hidden non-eager activity so DOM state is preserved. Two new system checks:A070(Warning — missingnameargument) andA071(Error — duplicate activity name within one template). Seedocs/website/guides/activity.mdfor the full guide +{% if %}/{% live_render %}/ sticky /dj-prefetchcomparison matrix. Demo atexamples/demo_project/djust_demos/views/activity_demo.py.- Intent-Based Prefetch (
dj-prefetch, v0.7.0) — hover- and touch-driven navigation prefetch that complements the existing service-worker-mediated hover prefetch. Links opting in with<a dj-prefetch href="...">are prefetched after a 65 ms hover debounce (cancelled onmouseleavebefore the debounce fires) and immediately ontouchstart— mobile users commit to a tap fast, so no debounce is applied there. Prefetch uses<link rel="prefetch" as="document">injection so the browser manages the cache lifecycle (falls back to low-priorityfetch+AbortControllerwhenrelListdoesn't advertise'prefetch'). Same-origin only;javascript:/data:URLs blocked; dedup'd per URL via a Set thatwindow.djust._prefetch.clear()wipes on SPA navigation. Opt out per-link withdj-prefetch="false". Respectsnavigator.connection.saveData. New client surface:window.djust._intentPrefetchfor test/diagnostic access. Scope: client-side only — no new server endpoint. Contract:dj-prefetchis intended for author-controlled navigation links only; don't put it on links that perform state-changing GETs (see the module header inpython/djust/static/djust/src/22-prefetch.jsfor the full safety contract). Seedocs/website/guides/prefetch.mdfor the guide and the SW-hover-vs-intent comparison table. - Server Functions (
@server_function/djust.call(), v0.7.0) — same-origin browser RPC without VDOM re-render. Decorate a LiveView method with@server_functionand invoke it from JavaScript asawait djust.call('<view_slug>', '<fn>', {params}); the return value is JSON-serialized straight back to the caller. The three primitives now split cleanly by intent:@event_handler— WebSocket, triggers a VDOM re-render (UI interactions: click, submit, input).@event_handler(expose_api=True)— HTTP (ADR-008), triggers a re-render AND exposes the handler to mobile / S2S / AI-agent callers via OpenAPI.@server_function— HTTP, no re-render, no OpenAPI, noapi_response/serialize=hooks. Designed exclusively for in-browser RPC; response envelope is the minimal{"result": <value>}. Session-cookie auth + CSRF are both required unconditionally — no auth-class opt-out. Request body shape is strict: only an empty body,{}, or{"params": {...}}are accepted; any other shape (flat objects, wrapped objects with sibling keys) returns400 invalid_body. This deliberately removes the ambiguity where a caller's own field namedparamswould be silently unwrapped and every sibling key dropped. The dispatcher reuses the ADR-008 pipeline unchanged: parameter coercion viavalidate_handler_params,@permission_requiredgating viacheck_handler_permission, and@rate_limitvia the same LRU-capped_rate_bucketsOrderedDict. Both sync andasync deffunctions are supported via_call_possibly_async. Stacking@event_handlerand@server_functionon the same method raisesTypeErrorat decoration time — a function either re-renders the view or returns an RPC result, never both. New URL:POST /djust/api/call/<view_slug>/ <function_name>/, declared BEFORE the catch-all dispatch pattern so it can't be shadowed. New public surface:djust.decorators.server_function,is_server_function,djust.api.DjustServerFunctionView,dispatch_server_function(inpython/djust/api/dispatch.py),iter_server_functions. New client modulepython/djust/static/djust/src/48-server-functions.js(~40 LOC, ~430 B gzipped delta). Demo:examples/demo_project/djust_demos/adds a product-search view demonstrating both features end-to-end. Seedocs/website/guides/server-functions.mdfor the full API reference, error-code table, and comparison vs.@event_handlerand@event_handler(expose_api=True).