Matthias Kestenholz: Posts about Djangohttps://406.ch/writing/category-django/2026-06-17T12:00:00ZMatthias KestenholzThe 2026 way of using importmaps in Djangohttps://406.ch/writing/the-2026-way-of-using-importmaps-in-django/2026-06-17T12:00:00Z2026-06-17T12:00:00Z<h1 id="the-2026-way-of-using-importmaps-in-django"><a class="toclink" href="#the-2026-way-of-using-importmaps-in-django">The 2026 way of using importmaps in Django</a></h1> <p>I last wrote about <a href="/writing/django-javascript-modules-and-importmaps/">Django, JavaScript modules and importmaps</a> in May 2025, slightly over a year ago.</p> <p>The main topic of this post is the <a href="https://github.com/feincms/django-js-asset">django-js-asset</a> 4.0 release. The library is used in many places, some of the more well-known packages using it are <a href="https://github.com/django-mptt/django-mptt">django-mptt</a> and <a href="https://github.com/django-ckeditor/django-ckeditor">django-ckeditor</a>. I have since done a lot of work evolving the ways of integrating importmaps but the efforts to standardize upon an approach have stalled a bit. The main reason for this, apart from time and energy, was that I wasn&rsquo;t really all that happy with the global importmap. When I had only a few modules using the importmap facility, I didn&rsquo;t care all that much. Now that the recently released <a href="https://github.com/feincms/django-content-editor">django-content-editor 9.0</a> also uses importmaps for shipping a refactored, much more modular JavaScript implementation while still keeping all the benefits of cache busting using <code>ManifestStaticFilesStorage</code><sup id="fnref:manifest"><a class="footnote-ref" href="#fn:manifest">1</a></sup>, having a global importmap got annoying. The content editor JavaScript is only used within the <a href="/writing/the-django-admin-is-a-cms/">Django administration interface</a>, but when using a single global importmap object, the importmap entries were always there on each page that used an importmap at all.</p> <p>A better solution was needed. I&rsquo;m a big fan of using <a href="https://docs.djangoproject.com/en/6.1/topics/forms/media/"><code>forms.Media</code></a> for collecting CSS and JavaScript from widgets, forms and utilities. It helps me avoid inline JavaScript since <a href="https://406.ch/writing/django-admin-apps-and-content-security-policy-compliance/">at least 2017</a>. I&rsquo;m not using it for site-wide CSS and JavaScript, I&rsquo;m still transpiling, PostCSS-ing and bundling the assets using <a href="https://rspack.rs/">rspack</a> as for example written about <a href="https://406.ch/writing/avoiding-empty-javascript-files-in-css-only-entrypoints-from-rspack-builds/">here</a> and <a href="https://406.ch/writing/how-i-m-bundling-frontend-assets-using-django-and-rspack-these-days/">here</a>.</p> <h2 id="why-importmaps"><a class="toclink" href="#why-importmaps">Why importmaps?</a></h2> <p>A quick refresher on why this matters at all. Django&rsquo;s <code>ManifestStaticFilesStorage</code> hashes the contents of each file into its name for cache busting, but out of the box it doesn&rsquo;t rewrite the <code>import</code> statements inside JavaScript modules. Importmaps bridge the gap: your code imports a stable name:</p> <div class="chl"><pre><span></span><code><span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">initializeEditors</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s2">&quot;django-prose-editor/editor&quot;</span> </code></pre></div> <p>and the importmap tells the browser where that name actually lives:</p> <div class="chl"><pre><span></span><code><span class="p">&lt;</span><span class="nt">script</span> <span class="na">type</span><span class="o">=</span><span class="s">&quot;importmap&quot;</span><span class="p">&gt;</span> <span class="p">{</span><span class="s2">&quot;imports&quot;</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="s2">&quot;django-prose-editor/editor&quot;</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;/static/django_prose_editor/editor.6e8dd4c12e2e.js&quot;</span> <span class="p">}}</span> <span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span> </code></pre></div> <p>So the import stays clean and constant while the file behind it can get a new hash on every deploy.</p> <h2 id="django-js-asset-40"><a class="toclink" href="#django-js-asset-40">django-js-asset 4.0</a></h2> <p>The updated <a href="https://github.com/feincms/django-js-asset">django-js-asset 4.0</a> doesn&rsquo;t ship the old, global importmap at all. This means the upgrade might require some work. Instead of one importmap shared across the whole site, you now get a specific importmap assembled for the context at hand &ndash; either by Django itself when it collects the media of your forms, widgets and the admin, or explicitly by you in a view or context processor. The building block in both cases is the <code>ImportMap</code> object; when it travels through <code>js_asset.Media</code> (a subclass of <code>django.forms.Media</code>) the maps are automatically merged into a single <code>&lt;script type="importmap"&gt;</code>, by customizing and extending what Django does already when merging media instances.</p> <p>The <a href="https://github.com/feincms/django-js-asset/blob/main/CHANGELOG.rst">release notes</a> go into more detail.</p> <h2 id="in-practice"><a class="toclink" href="#in-practice">In practice</a></h2> <p>If you&rsquo;re using a package such as <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a> in the Django admin you don&rsquo;t have to do anything, things should just work.</p> <p>If you&rsquo;re using such a package outside the admin, you have to remove <code>"js_asset.context_processors.importmap"</code> from your list of context processors. On one particular website the prose editor is the only package with importmap entries outside the admin, so I have to add the <code>importmap</code> to the template context myself:</p> <div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django_prose_editor.widgets</span><span class="w"> </span><span class="kn">import</span> <span class="n">importmap</span> <span class="k">def</span><span class="w"> </span><span class="nf">view</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="o">...</span><span class="p">):</span> <span class="k">return</span> <span class="n">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="s2">&quot;template.html&quot;</span><span class="p">,</span> <span class="p">{</span> <span class="c1"># ...</span> <span class="s2">&quot;importmap&quot;</span><span class="p">:</span> <span class="n">importmap</span><span class="p">,</span> <span class="p">})</span> </code></pre></div> <p>The template then just renders it in the <code>&lt;head&gt;</code>:</p> <div class="chl"><pre><span></span><code>... {{ importmap }}<span class="p">&lt;/</span><span class="nt">head</span><span class="p">&gt;</span> </code></pre></div> <p>On a different site, I have a slightly more involved scenario where I previously used <code>importmap.update(...)</code> to add my own entries to the importmap. There, I&rsquo;m using a custom context processor to always add these entries to the importmap too:</p> <div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django_prose_editor.widgets</span><span class="w"> </span><span class="kn">import</span> <span class="n">importmap</span> <span class="k">as</span> <span class="n">dpe_importmap</span> <span class="kn">from</span><span class="w"> </span><span class="nn">js_asset</span><span class="w"> </span><span class="kn">import</span> <span class="n">ImportMap</span><span class="p">,</span> <span class="n">static_lazy</span> <span class="n">_site_importmap</span> <span class="o">=</span> <span class="n">ImportMap</span><span class="p">({</span> <span class="s2">&quot;imports&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&quot;my-module&quot;</span><span class="p">:</span> <span class="n">static_lazy</span><span class="p">(</span><span class="s2">&quot;my-module.js&quot;</span><span class="p">),</span> <span class="p">}</span> <span class="p">})</span> <span class="n">_importmap</span> <span class="o">=</span> <span class="n">dpe_importmap</span> <span class="o">|</span> <span class="n">_site_importmap</span> <span class="k">def</span><span class="w"> </span><span class="nf">importmap</span><span class="p">(</span><span class="n">request</span><span class="p">):</span> <span class="k">return</span> <span class="p">{</span><span class="s2">&quot;importmap&quot;</span><span class="p">:</span> <span class="n">_importmap</span><span class="p">}</span> </code></pre></div> <p>This importmap is merged once at server startup and then served repeatedly to the client. Because we use the lazy version of the <code>static</code> function we can do this during startup and not worry about files not yet collected by <code>collectstatic</code> &ndash; we&rsquo;ll get the correct paths later.</p> <p>On the same site as the previous example, I also have an admin inline which requires some JavaScript and also an importmap:</p> <div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django.contrib</span><span class="w"> </span><span class="kn">import</span> <span class="n">admin</span> <span class="kn">from</span><span class="w"> </span><span class="nn">django.forms</span><span class="w"> </span><span class="kn">import</span> <span class="n">Script</span> <span class="kn">from</span><span class="w"> </span><span class="nn">js_asset</span><span class="w"> </span><span class="kn">import</span> <span class="n">Media</span><span class="p">,</span> <span class="n">ImportMap</span> <span class="c1"># Initializing this once. Not necessary but I like it better that way.</span> <span class="n">_importmap</span> <span class="o">=</span> <span class="n">ImportMap</span><span class="p">({</span> <span class="s2">&quot;imports&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="c1"># ...</span> <span class="p">}</span> <span class="p">})</span> <span class="k">class</span><span class="w"> </span><span class="nc">ModelInline</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">StackedInline</span><span class="p">):</span> <span class="nd">@property</span> <span class="k">def</span><span class="w"> </span><span class="nf">media</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span> <span class="k">return</span> <span class="n">Media</span><span class="p">(</span> <span class="n">js</span><span class="o">=</span><span class="p">[</span> <span class="n">_importmap</span><span class="p">,</span> <span class="n">Script</span><span class="p">(</span><span class="s2">&quot;module.js&quot;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="s2">&quot;module&quot;</span><span class="p">),</span> <span class="p">]</span> <span class="p">)</span> </code></pre></div> <p>As of 4.0, <code>JS</code> and <code>CSS</code> produce Django&rsquo;s own <code>Script</code> and <code>Stylesheet</code> objects, so you can import and use <code>Script</code> directly from <code>django.forms</code> as shown above (on Django 4.2–5.1, import it from <code>js_asset</code> instead, which backports it). The familiar <code>JS("module.js", {"type": "module"})</code> wrapper still works unchanged if you prefer it — it just takes a positional dict instead of keyword arguments.</p> <p>Here, it&rsquo;s really important to use the <code>js_asset.Media</code> and not <code>django.forms.Media</code>. <code>js_asset.Media</code> knows how to handle importmaps &ndash; all importmaps are collected from all media lists, merged and added to the output before all other CSS and especially JavaScript. The reason for that is that browsers only honour a single importmap per page, and it really has to appear before all JavaScript modules referencing any entries in the importmap.</p> <p>The nice thing about <code>js_asset.Media</code> is that it doesn&rsquo;t have to appear first in the list of media classes which are merged &ndash; it can also appear in the middle or last, and still can do its magic after all <code>Media</code> objects have been merged into a single one.</p> <p>The rest is handled by Django itself, since it already supports collecting media assets. The missing piece was just the importmap object and the <code>js_asset.Media</code> class which knows how to special case them, and which &ndash; through the power of overriding <code>__add__</code> and <code>__radd__</code> takes over all the other media instances.</p> <h2 id="whats-next"><a class="toclink" href="#whats-next">What&rsquo;s next</a></h2> <p>I haven&rsquo;t yet used CSP nonces using <a href="https://docs.djangoproject.com/en/6.1/ref/templates/builtins/#std-templatetag-csp_nonce_attr"><code>{% csp_nonce_attr media %}</code></a> in production myself, but it should just work, even with importmaps and everything else. Given that I have a passing test suite I have no reason to believe it doesn&rsquo;t already work, but I&rsquo;d like to have a confirmation.</p> <p>I&rsquo;m hoping to standardize some more. If we could get something like this in Django core that would be really nice. Maybe I&rsquo;ll be able to work on that at <a href="https://djangomed.eu/">Django on the Med 🏖️</a>. Since no browser supports multiple importmaps as of today having multiple implementations of importmaps in the Django ecosystem will lead to trouble down the road. I think there is a clear case to be made for importmap support in Django and I would obviously love it if the approach implemented today in django-js-asset would be the basis for the official solution.</p> <div class="footnote"> <hr /> <ol> <li id="fn:manifest"> <p>Without having to do any overrides to enable ESM support.&#160;<a class="footnote-backref" href="#fnref:manifest" title="Jump back to footnote 1 in the text">&#8617;</a></p> </li> </ol> </div>«Anything new?»https://406.ch/writing/anything-new/2026-06-03T12:00:00Z2026-06-03T12:00:00Z<h1 id="anything-new"><a class="toclink" href="#anything-new">«Anything new?»</a></h1> <p>A lot of time has passed since I officially announced that I want to <a href="https://406.ch/writing/weeknotes-2021-week-10/#django-mptt-is-not-maintained-anymore">step down from maintaining django-mptt</a>. I started contributing around 2009, tagged the <a href="https://github.com/django-mptt/django-mptt/tree/0.3.0">0.3 release in April 2010</a>, and have been the sole active maintainer since somewhere around 2019. The post about <a href="https://406.ch/writing/django-tree-queries/">django-tree-queries</a> has more background, but that&rsquo;s not today&rsquo;s topic.</p> <h2 id="stepping-away-isnt-easy"><a class="toclink" href="#stepping-away-isnt-easy">Stepping away isn&rsquo;t easy</a></h2> <p>For me, abandoning a project is a bit like stepping out of a relationship: negative emotions end up being a somewhat necessary driver, because the absence of positive events alone rarely provides enough force on its own. I get a lot of satisfaction from a job well done, and walking away means letting that go.</p> <p>Even with time set aside for open source in my work day, I still have to choose where that time goes. django-mptt stopped being where it needed to go.</p> <h2 id="the-sense-of-entitlement"><a class="toclink" href="#the-sense-of-entitlement">The sense of entitlement</a></h2> <p>When a project is obviously unmaintained, asking for free labor is walking a tightrope. It takes real care not to rekindle exactly the frustrations that led maintainers away in the first place.</p> <p>It takes energy not to clap back when someone is being rude or insensitive in the issue tracker. Asking &ldquo;Anything new?&rdquo; on a ticket where the next steps were outlined clearly and obviously nothing happened in the meantime is just one variant of this.</p> <p>Quietly quitting isn&rsquo;t what I want to do — and as a user of django-mptt myself, I can&rsquo;t really do that either. Taking the high road is the professional choice. But it costs something.</p> <p>I keep coming back to <a href="https://youtu.be/Amj3QG2s1BI?t=226">Mona Eltahawy on refusing to be civil</a>. She&rsquo;s speaking about something quite different, and I&rsquo;m aware I write this as a white man. The situations aren&rsquo;t the same at all. But she articulated something I haven&rsquo;t managed to put into words as well myself and I like the idea of speaking up and taking the fight to those who awaken these feelings instead of taking the high road.</p> <h2 id="doing-it-with-ai"><a class="toclink" href="#doing-it-with-ai">Doing it with AI</a></h2> <p>No post these days is complete without the obligatory AI mention, but there&rsquo;s some relevancy to it.</p> <p>I fixed and closed almost all open django-mptt issues in a two-hour Claude session. I&rsquo;ve previously written about using <a href="https://406.ch/writing/llms-for-open-source-maintenance-a-cautious-case/">LLMs for open source maintenance</a>, and the productivity gain is real whatever the detractors say. And the quality isn&rsquo;t suddenly getting worse. Code wasn&rsquo;t perfect before either. The test suite allows a certain degree of trust in the result and according to <a href="https://406.ch/writing/my-rules-for-releasing-open-source-software/">my rules for releasing Open Source software</a> we don&rsquo;t have to require more than that.</p> <p>It doesn&rsquo;t change the underlying dynamic, though. <a href="https://medium.com/@tridge60/rsync-and-outrage-d9849599e5a0">rsync and outrage</a> illustrates the trap neatly: Tridgell got flooded with AI-generated security reports, used AI to handle them, and then got criticized for using AI. The tools that created the workload aren&rsquo;t allowed to address it. The expectation is that the work has to involve sweat and tears and uncountable unpaid hours.</p> <p>The common goal should be more and better open source software. What we get as Open Source maintainers is shit from both sides: One side took our free work and trained models on it without asking, the other side complains about the supposedly unethical use of AI while acting in unethical ways themselves.</p> <p>There&rsquo;s something Kantian about how open source contribution gets framed. <a href="https://en.wikipedia.org/wiki/Immanuel_Kant">Kant</a>&rsquo;s argument was that the only truly moral acts are those driven by duty and good will — not by desire, inclination, or any expectation of compensation. By that logic, I&rsquo;m only acting morally if I keep going despite the burnout and the entitlement. If I stop, I&rsquo;m not.</p> <p>It&rsquo;s bleak. The problems with AI are real. The people controlling the large models are assholes. But I have to work in the world as it is while also trying to change it for the better.</p>Weeknotes (2026 week 17)https://406.ch/writing/weeknotes-2026-week-17/2026-05-20T12:00:00Z2026-05-20T12:00:00Z<h1 id="weeknotes-2026-week-17"><a class="toclink" href="#weeknotes-2026-week-17">Weeknotes (2026 week 17)</a></h1> <p>I published the <a href="https://406.ch/writing/weeknotes-2026-week-11/">last entry</a> near the beginning of March. I&rsquo;m really starting to see a theme in my Weeknotes publishing schedule.</p> <h2 id="releases-since-the-first-weeks-of-march"><a class="toclink" href="#releases-since-the-first-weeks-of-march">Releases since the first weeks of March</a></h2> <p>I&rsquo;m trying out a longer-form version of those notes here than in the past. I think it&rsquo;s worth going into some detail and not just listing releases with half a sentence each.</p> <h3 id="feincms3-sites-and-feincms3-language-sites"><a class="toclink" href="#feincms3-sites-and-feincms3-language-sites">feincms3-sites and feincms3-language-sites</a></h3> <p>I released updates to <a href="https://pypi.org/project/feincms3-sites/">feincms3-sites</a> and <a href="https://pypi.org/project/feincms3-language-sites/">feincms3-language-sites</a> fixing the same issue in both projects: When an HTTP client didn&rsquo;t strip the default ports :80 (for HTTP) or :443 (for HTTPS) from a request, finding the correct site would fail. Browsers generally strip the port already, but some other HTTP clients do not.</p> <h3 id="django-tree-queries"><a class="toclink" href="#django-tree-queries">django-tree-queries</a></h3> <p>As I wrote <a href="/writing/llms-for-open-source-maintenance-a-cautious-case/">elsewhere</a> I closed many issues in the repositories, mostly documentation issues but also some bugs. <code>{% recursetree %}</code> should now work properly and not cache old data anymore, using the primary key in <code>.tree_fields()</code> now raises an intelligible error, and I also fixed a bug with table quoting when using <a href="https://pypi.org/project/django-tree-queries/">django-tree-queries</a> with the not yet released Django 6.1+.</p> <h3 id="feincms3-cookiecontrol"><a class="toclink" href="#feincms3-cookiecontrol">feincms3-cookiecontrol</a></h3> <p><a href="https://pypi.org/project/feincms3-cookiecontrol/">feincms3-cookiecontrol</a> not only offers a cookie consent banner (which actually supports <a href="https://406.ch/writing/reusable-cookie-consent-app-for-django/">only embedding tracking scripts when users give consent</a>) but also a third-party content embedding functionality which allows allowlisting individual services.</p> <p>The privacy policies of these services are now linked inline instead of with an ugly extra link. This reduces content inside the embed which helps on small screens.</p> <p>Version 1.7 used a buggy trusted publishing workflow so I immediately published 1.7.1.</p> <h3 id="django-cabinet-and-django-prose-editor"><a class="toclink" href="#django-cabinet-and-django-prose-editor">django-cabinet and django-prose-editor</a></h3> <p><a href="https://pypi.org/project/django-cabinet/">django-cabinet</a> can now be used as a media library directly inside <a href="https://pypi.org/project/django-prose-editor/">django-prose-editor</a>. I&rsquo;m (ab)using the CKEditor 4 protocol for embedding, which uses <code>window.opener.CKEDITOR.callFunction</code> to send data back from the file manager popup into the editor. It feels icky but works nicely. This is only available if you&rsquo;re installing the alpha prereleases, but I&rsquo;m already testing the functionality in production somewhere, so I feel quite good about it.</p> <p>django-prose-editor now also ships brand new <a href="https://django-prose-editor.readthedocs.io/en/latest/classloom.html"><code>ClassLoom</code></a> and <a href="https://django-prose-editor.readthedocs.io/en/latest/styleloom.html"><code>StyleLoom</code></a> extensions. Both extensions allow adding either classes or inline styles to text spans or nodes. In an ideal world we might not use something like this, but to make the editor more useful in the real world, editors need more flexibility. These two extensions provide that. I already mentioned <code>ClassLoom</code> <a href="/writing/rich-text-editors-how-restrictive-can-we-be/#combining-css-classes">in December</a>, now it&rsquo;s actually available. I&rsquo;m not completely sold on how they work yet, but both of them are already solving real issues.</p> <h3 id="honorable-mentions"><a class="toclink" href="#honorable-mentions">Honorable mentions</a></h3> <p><a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar 6.3</a> has been released, I only contributed reviews during this cycle.</p>Switching all of my Python packages to PyPI trusted publishinghttps://406.ch/writing/switching-all-of-my-python-packages-to-pypi-trusted-publishing/2026-04-08T12:00:00Z2026-04-08T12:00:00Z<h1 id="switching-all-of-my-python-packages-to-pypi-trusted-publishing"><a class="toclink" href="#switching-all-of-my-python-packages-to-pypi-trusted-publishing">Switching all of my Python packages to PyPI trusted publishing</a></h1> <p>As I have teased on <a href="https://hachyderm.io/@matthiask/116300209761371116">Mastodon</a>, I&rsquo;m switching all of my packages to PyPI trusted publishing. I have been using it to release the <a href="github.com/django-commons/django-debug-toolbar">django-debug-toolbar</a> a few times but never set it up myself. The process seemed tedious.</p> <p>The malicious releases uploaded to PyPI two weeks ago and the blog post about <a href="https://snarky.ca/why-pylock-toml-includes-digital-attestations/">digital attestations in <code>pylock.toml</code></a> finally pushed me to make the switch. All of my PyPI tokens have been revoked so there is no quick shortcut.</p> <div class="admonition note"> <p class="admonition-title">Note</p> <p>I&rsquo;m also looking at other code hosting platforms. I have been using git before GitHub existed and I&rsquo;ll probably still use git when GitHub has completed its enshittification. For now the cost/benefit ratio of staying on GitHub is still positive for me. Trusted publishing isn&rsquo;t available everywhere, so for now it is GitHub anyway.</p> </div> <p>In the end, switching an existing project was easier than expected. I have completed the process for <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a> and <a href="https://github.com/feincms/feincms3-cookiecontrol/">feincms3-cookiecontrol</a>.</p> <p>For my future benefit, here are the step by step instructions I have to follow:</p> <ol start="0"> <li> <p>Have a package which is buildable using e.g. <code>uvx build</code></p> </li> <li> <p>On PyPI add a trusted publisher in the project&rsquo;s publishing settings:</p> <ul> <li>Owner: <code>matthiask</code>, <code>feincms</code>, <code>feinheit</code>, whatever the user or organization&rsquo;s name is.</li> <li>Repository: <code>django-prose-editor</code></li> <li>Workflow name: <code>publish.yml</code></li> <li>Environment: <code>release</code></li> </ul> </li> <li> <p>In the GitHub repository, create a <code>release</code> environment in Settings / Environments. If I want to manually approve releases I&rsquo;ll add myself and potentially also other releasers as required reviewers.</p> </li> <li> <p>Run <code>git tag x.y.z</code> and <code>git push</code>, no more <code>uvx twine</code> or <code>hatch publish</code>.</p> </li> <li> <p>Approve the release in the actions tab on the repository.</p> </li> <li> <p>Either enjoy or swear and repeat the steps.</p> </li> </ol> <p>I&rsquo;m happy with testing the release process in production. The older I get the less I care if people think I&rsquo;m stupid. That&rsquo;s also why feincms3-cookiecontrol 1.7.0 doesn&rsquo;t exist, only 1.7.1 &ndash; the process failed and I had to bump the patch version and try again. Copy the <code>publish.yml</code> from a known good place, for example from the <a href="https://github.com/feincms/django-prose-editor/blob/main/.github/workflows/publish.yml">django-prose-editor repository</a>. I have added the <code>if: github.repository == 'feincms/django-prose-editor'</code> statement which ensures that the workflow only runs in the main repository, but that&rsquo;s optional if you don&rsquo;t care about failing workflows.</p>LLMs for Open Source maintenance: a cautious casehttps://406.ch/writing/llms-for-open-source-maintenance-a-cautious-case/2026-03-25T12:00:00Z2026-03-25T12:00:00Z<h1 id="llms-for-open-source-maintenance-a-cautious-case"><a class="toclink" href="#llms-for-open-source-maintenance-a-cautious-case">LLMs for Open Source maintenance: a cautious case</a></h1> <p>When ChatGPT appeared on the scene I was very annoyed at all the hype surrounding it. Since I&rsquo;m working in the fast moving and low margin business of communication and campaigning agencies I&rsquo;m surrounded by people eager to jump on the hype train when a tool promises to lessen the workload and take stuff from everyone&rsquo;s plate.</p> <p>These discussions coupled with the fact that the training of these tools required unfathomable amounts of <strong>stealing</strong> were the reason for a big reluctance on my part when trying them out. I&rsquo;m using the word stealing here on purpose, since that&rsquo;s exactly the crime <a href="https://en.wikipedia.org/wiki/Aaron_Swartz">Aaron Swartz</a> <a href="https://web.archive.org/web/20120526080523/http://www.justice.gov/usao/ma/news/2011/July/SwartzAaronPR.html">was accused of by the attorney&rsquo;s office of the district of Massachusetts</a>. It&rsquo;s frustrating that some people can get away with the same crime when it is so much bigger. For example, OpenAI and Anthropic downloaded much more data than Aaron ever did.</p> <p>A somewhat related thing happened with the too-big-to-fail banks: There, the people at the top were even compensated with <a href="https://en.wikipedia.org/wiki/Golden_parachute">golden parachutes</a> at the end. LLM companies seem to be above accountability too.</p> <p>Despite all this, I have slowly started integrating these tools into my workflows. I don&rsquo;t remember the exact point in time, but since some time in 2025 my opinions on their utility has started to change. At the beginning, I always removed the attribution and took great care to write and rewrite the code myself, only using the LLMs for inspiration and maybe to generate integration tests. More and more I have to admit that they are useful, especially in time constrained projects with a clear focus and purpose.</p> <p>Last month I fixed and/or closed all open issues in the <a href="https://github.com/feincms/django-tree-queries">django-tree-queries</a> repository with the help of Claude Code. Is that a good thing? It could be argued I should have done the work myself. But I wouldn&rsquo;t have — I have other things I want to do with my time. I don&rsquo;t want to (always) work on Open Source software in the evening. I definitely also have leaned heavily on LLMs when working on <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a>.</p> <h2 id="is-faster-better"><a class="toclink" href="#is-faster-better">Is faster better?</a></h2> <p>We can produce more code, more features and close tickets faster than before. In my experience the speed up isn&rsquo;t as big as some people may want us to believe, but it&rsquo;s there. And contrary to what people in my LinkedIn feed say, that&rsquo;s not an obviously good thing. Is it a race to the bottom where we drown in LLM-generated slop in quantities impossible to maintain? It doesn&rsquo;t feel like that — but it&rsquo;s a race that could go both ways. Throwaway code can be thrown away though, and well tested code does what the tests say, which is good enough according to my <a href="https://406.ch/writing/my-rules-for-releasing-open-source-software/">rules for releasing open source software</a>.</p> <p>Speaking as someone who has put more into the training set than they&rsquo;ve taken out so far, I don&rsquo;t feel all that bad using the tools. Coding agents can already be run locally with reasonable hardware requirements, at least during inference, which is where the ongoing cost sits. Maybe using them is still rationalization. But contribution and profit needing to stay in some rough balance feels like the right frame. Total abstinence isn&rsquo;t the only ethical choice we have.</p> <h2 id="community-tensions"><a class="toclink" href="#community-tensions">Community tensions</a></h2> <p>What makes me less comfortable is how communities are reacting. There are real concerns within the Django world, and not just the practical one of overworked maintainers wading through hastily generated patches that don&rsquo;t actually fix anything. The deeper worry is about the communal nature of contribution: that working on Django is supposed to be a learning experience, a way into the community, and that using an LLM as a vehicle rather than a tool hollows out that process. Reviewers end up interacting with what is essentially a facade, unable to tell whether anyone actually understood the problem. That&rsquo;s a real concern and I don&rsquo;t want to dismiss it.</p> <p>But it maps onto a different situation from what I&rsquo;ve been describing. Using Claude Code to close issues in projects I maintain and understand is not the same as using it to paper over gaps in comprehension on a ticket in someone else&rsquo;s project. Whether LLM-assisted contributions to Django itself are appropriate is a difficult question; whether it&rsquo;s appropriate to use them when maintaining your own software less so.</p> <p>There&rsquo;s also a harder tension around quality. Django&rsquo;s conservatism has real value: rigorous review, minimal magic, a coherent philosophy. The ORM and template system don&rsquo;t need to reinvent themselves, they work well, are still evolving while staying rock-solid for all my use cases. And reading the release notes always brings me joy. But it could be more exciting more often. Quality isn&rsquo;t a strictly positive thing. Everything has costs. It&rsquo;s not great if the price of the bar is that legitimate bugs sit open for years because nobody has a few evenings to spend on them. It happened with django-tree-queries before I went through it with Claude Code. I think the bar for contributing to Django is too high. I would value a little more motion and a little less stability, even as someone running dozens of Django websites and apps.</p> <p>Then there&rsquo;s the pile-on dynamic that plays out on Mastodon and GitHub. When the Harfbuzz and chardet maintainers disclosed LLM usage, the reaction from some corners was something to behold. People expressing what amounted to personal grievance over tooling choices in projects they may not even use. There&rsquo;s a particular kind of entitlement in telling a maintainer &ndash; who is keeping software alive, possibly even in their spare time &ndash; that the way they choose to do that work is an affront. Open source is a gift, whether paid or not, and nobody has to accept it, but disclosing your tooling isn&rsquo;t an invitation for complaints. The ethical concerns about training data, resource use and other negative externalities are legitimate and worth raising. Performative outrage directed at individual maintainers is not the same thing.</p> <p>I don&rsquo;t have an easy conclusion. The tools are useful, the ethics are murky, and communities are still figuring out how to respond. A cautious, honest use of them feels better to me than the alternatives.</p>Weeknotes (2026 week 11)https://406.ch/writing/weeknotes-2026-week-11/2026-03-11T12:00:00Z2026-03-11T12:00:00Z<h1 id="weeknotes-2026-week-11"><a class="toclink" href="#weeknotes-2026-week-11">Weeknotes (2026 week 11)</a></h1> <p>Last time I wrote that I seem to be publishing weeknotes monthly. Now, a quarter of a year has passed since the <a href="https://406.ch/writing/weeknotes-2025-week-49/">last entry</a>. I do enjoy the fact that I have published more posts focused on a single topic. That said, what has been going on in open source land is certainly interesting too.</p> <h2 id="llms-in-open-source"><a class="toclink" href="#llms-in-open-source">LLMs in Open Source</a></h2> <p>I have started a longer piece to think about my stance regarding using LLMs in Open Source. The argument I&rsquo;m thinking about is that there&rsquo;s a balance between LLMs having ingested all of my published open source code and myself using them now to help myself and others again.</p> <p>The happenings in the last two weeks (think Pentagon, Iran, and the bombings of schools) have again brought to the foreground the perils of using those tools. I therefore haven&rsquo;t been motivated to pursue this train of thought for the moment. When the upsides are somewhat questionable and tentative and the downsides are so clear and impossible to miss, it&rsquo;s hard to use my voice to speak in favor of these tools.</p> <p>That said, all the shaming when someone uses an LLM that I see in my Mastodon feed also annoys me. I&rsquo;ll quote part of a post here which I liked and leave it at that for the moment:</p> <blockquote> <p>The AI hype-cyclone is bad, but so is the anti-AI witch hunt. Commits co-authored by Claude do not mean that a project has &ldquo;abandoned engineering as a serious endeavor&rdquo;</p> <p>[&hellip;]</p> </blockquote> <p>&ndash; <a href="https://hachyderm.io/@nedbat/116133445557306539">@nedbat on Mastodon</a></p> <h2 id="other-goings-on"><a class="toclink" href="#other-goings-on">Other goings-on</a></h2> <ul> <li><strong>Health:</strong> My back continues to improve. Some days are still bad, but the idea that the <a href="https://406.ch/writing/my-2025-in-review/#sports-and-health">herniation</a> may go away entirely doesn&rsquo;t sound totally unreasonable anymore.</li> <li><strong>Gardening:</strong> We started weeding the garden last week. Lots to do! Being outside is fun. Weeding isn&rsquo;t the greatest part ever, but it&rsquo;s meditative.</li> </ul> <h2 id="releases-since-december"><a class="toclink" href="#releases-since-december">Releases since December</a></h2> <ul> <li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.12.1</a>: CSS fixes. I have again looked at other, more modern JSON schema editor implementations but all of them are more limited than is acceptable to act as a replacement.</li> <li><a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar 6.2</a>: I haven&rsquo;t done much work here! Just some reviewing.</li> <li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 8.1</a>: Started emitting warnings when using non-abstract base classes for plugins. Using multi table inheritance is mostly an accident and not intended in my experience when using django-content-editor, therefore we have started detecting this case and emitting system checks (warnings, not errors).</li> <li><a href="https://pypi.org/project/django-imagefield/">django-imagefield 0.23.0a3</a>: We have done some work on supporting <a href="https://www.libvips.org/">libvips</a> as an alternative backend to Pillow because I hoped that memory usage in Kubernetes pods might go down a bit. Results are not conclusive yet, and I&rsquo;m not yet convinced the additional code complexity is worth it. Debugging and monitoring continues.</li> <li><a href="https://pypi.org/project/FeinCMS/">FeinCMS 26.2.1</a>: Released a few bugfixes. FeinCMS is still being maintained ~17 years later!</li> <li><a href="https://pypi.org/project/django-auto-admin-fieldsets/">django-auto-admin-fieldsets 0.3</a>: Added a helper to remove fields from the fieldsets structure.</li> <li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.23.1</a>: Shipped a small bugfix for <code>{% recursetree %}</code> which unintentionally cached children across invocations.</li> <li><a href="https://pypi.org/project/feincms3-downloads/">feincms3-downloads</a>: Used <code>PATH</code> from the environment instead of using a very restricted allowlist so that <code>convert</code> and <code>pdftocairo</code> are detected in more locations. This should help with local development for example on macOS.</li> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.24.1</a>: Read the <a href="https://django-prose-editor.readthedocs.io/en/latest/changelog.html">CHANGELOG</a>; there&rsquo;s too much in there for a short notice.</li> <li><a href="https://pypi.org/project/form-designer/">form-designer 0.27.3</a>: Mosparo captcha support, bugfixes and additional translations.</li> <li><a href="https://pypi.org/project/feincms3/">feincms3 5.5</a>: Started using the <code>OrderableTreeNode</code> from django-tree-queries.</li> </ul>Rich text editors: How restrictive can we be?https://406.ch/writing/rich-text-editors-how-restrictive-can-we-be/2025-12-17T12:00:00Z2025-12-17T12:00:00Z<h1 id="rich-text-editors-how-restrictive-can-we-be"><a class="toclink" href="#rich-text-editors-how-restrictive-can-we-be">Rich text editors: How restrictive can we be?</a></h1> <p>How restrictive should a rich text editor be? It&rsquo;s a question I keep coming back to as I work on FeinCMS and Django-based content management systems.</p> <p>I published the last blog post on <a href="https://github.com/feincms/django-prose-editor">django-prose-editor</a> specifically in August 2025, <a href="https://406.ch/writing/menu-improvements-in-django-prose-editor/">Menu improvements in django-prose-editor</a>. The most interesting part of the blog post was the short mention of the <code>TextClass</code> extension at the bottom which allows adding a predefined list of CSS classes to arbitrary spans of text.</p> <p>In the meantime, I have spent a lot of time working on extensions that try to answer this question: the <a href="https://django-prose-editor.readthedocs.io/en/latest/textclass.html"><code>TextClass</code> extension</a> for adding CSS classes to inline text, and more recently the <a href="https://django-prose-editor.readthedocs.io/en/latest/nodeclass.html"><code>NodeClass</code> extension</a> for adding classes to nodes and marks. It&rsquo;s high time to write a post about it.</p> <h2 id="rich-text-editing-philosophy"><a class="toclink" href="#rich-text-editing-philosophy">Rich Text editing philosophy</a></h2> <blockquote> <p>All of this convinced me that offering the user a rich text editor with too much capabilities is a really bad idea. The rich text editor in FeinCMS only has bold, italic, bullets, link and headlines activated (and the HTML code button, because that&rsquo;s sort of inevitable – sometimes the rich text editor messes up and you cannot fix it other than going directly into the HTML code. Plus, if someone really knows what they are doing, I&rsquo;d still like to give them the power to shoot their own foot).</p> </blockquote> <p>&ndash; <a href="https://github.com/feincms/feincms/commit/70cd7a1244438d2ba97852256f77daa2c870c345#diff-556c5559a716059d4fb714ad34de6a9845870e8d55bbd2cb9d77c732eb961388">Commit in the FeinCMS repository, August 2009</a>, current version from <a href="https://django-content-editor.readthedocs.io/en/latest/design-decisions.html">django-content-editor design decisions</a></p> <h2 id="should-we-let-users-shoot-themselves-in-the-foot"><a class="toclink" href="#should-we-let-users-shoot-themselves-in-the-foot">Should we let users shoot themselves in the foot?</a></h2> <p>Giving power users an HTML code button would have been somewhat fine if only the editors themselves were affected. Unfortunately, that was not the case.</p> <p>As a team we have spent more time than we ever wanted debugging strange problems only to find out that the culprit was a blob of CSS or JavaScript inserted directly into an unsanitized rich text editor field. We saw everything from a few reasonable and well scoped lines of CSS to hundreds of KiBs of hotlinked JavaScript code that broke layouts, caused performance issues, and possibly even created security vulnerabilities.</p> <p>We have one more case of <a href="https://en.wikipedia.org/wiki/Betteridge%27s_law_of_headlines">Betteridge&rsquo;s law of headlines</a> here.</p> <h2 id="the-pendulum-swings"><a class="toclink" href="#the-pendulum-swings">The pendulum swings</a></h2> <p>The first version of django-prose-editor which replaced the venerable CKEditor 4 in our project was much more strict and reduced &ndash; no attributes, no classes, just a very short list of allowlisted HTML tags in the schema.</p> <p>We quickly hit some snags. When users needed similar headings with different styles, we worked around it by using H2 and H3 — not semantic at all. I wasn&rsquo;t exactly involved in this decision; I just didn&rsquo;t want to rock the boat too much, since I was so happy that we were even able to use the more restricted editor at all in this project.</p> <p>Everything was good for a while, but more and more use cases crept up until it was clear that something had to be done about it. First, the <a href="https://django-prose-editor.readthedocs.io/en/latest/textclass.html"><code>TextClass</code> extension</a> was introduced to allow adding classes to inline text, and later also the <a href="https://django-prose-editor.readthedocs.io/en/latest/nodeclass.html"><code>NodeClass</code> extension</a> mentioned above. This was a compromise: The customer wanted inline styles, we wanted as little customizability as possible without getting in the way.</p> <p>That said, we obviously had to move a bit. After all, going back to a less strict editor or even offering a HTML blob injection would be worse. If we try to be too restrictive we will probably have to go back to allowing everything some way or the other, after all:</p> <blockquote> <p>The more you tighten your grip, Tarkin, the more star systems will slip through your fingers.</p> </blockquote> <p>&ndash; Princess Leia</p> <h2 id="combining-css-classes"><a class="toclink" href="#combining-css-classes">Combining CSS classes</a></h2> <p>The last words are definitely not spoken just yet. As <a href="https://hachyderm.io/@matthiask/115650714479718340">teased on Mastodon</a> at the beginning of this month I am working on an even more flexible extension which unifies the <code>NodeClass</code> and <code>TextClass</code> extensions into a single <code>ClassLoom</code> extension.</p> <p>The code is getting real world use now, but I&rsquo;m not ready to integrate it yet into the official repository. However, you can use it if you want, it&rsquo;s 1:1 the version from a project repository. <a href="https://gist.github.com/matthiask/64ea64b539d63d45ff71467752c2f307">Get the <code>ClassLoom</code> extension here</a>.</p> <p>This extension also allows combining classes on a single element. If you have 5 colors and 3 text styles, you&rsquo;d have to add 15 combinations if you were only able to apply a single class. Allowing combinations brings the number of classes down to manageable levels.</p> <h2 id="conclusion"><a class="toclink" href="#conclusion">Conclusion</a></h2> <p>So, back to the original question: How restrictive can we be?</p> <p>The journey from CKEditor 4&rsquo;s permissiveness through django-prose-editor&rsquo;s initial strictness to today&rsquo;s <code>ClassLoom</code> extension has been one of finding that balance. Each extension — <code>TextClass</code>, <code>NodeClass</code>, and now <code>ClassLoom</code> — represents a step toward controlled flexibility: giving content editors the styling options they need while keeping the content structured, maintainable, and safe.</p>Weeknotes (2025 week 49)https://406.ch/writing/weeknotes-2025-week-49/2025-12-05T12:00:00Z2025-12-05T12:00:00Z<h1 id="weeknotes-2025-week-49"><a class="toclink" href="#weeknotes-2025-week-49">Weeknotes (2025 week 49)</a></h1> <p>I seem to be publishing weeknotes monthly, so I&rsquo;m now thinking about renaming the category :-)</p> <h2 id="mosparo"><a class="toclink" href="#mosparo">Mosparo</a></h2> <p>I have started using a self-hosted <a href="https://mosparo.io/">mosparo</a> instance for my captcha needs. It&rsquo;s nicer than Google reCAPTCHA. Also, not sending data to Google and not training AI models on traffic signs feels better.</p> <h2 id="fixes-for-the-youtube-153-error"><a class="toclink" href="#fixes-for-the-youtube-153-error">Fixes for the YouTube 153 error</a></h2> <p>Simon Willison published a nice writeup about <a href="https://simonwillison.net/2025/Dec/1/youtube-embed-153-error/">YouTube embeds failing with a 153 error</a>. We have also encountered this problem in the wild and fixed the <a href="https://feincms3.readthedocs.io/en/latest/ref/embedding.html">feincms3</a> embedding code to <a href="https://github.com/feincms/feincms3/commit/3f00e5d2a15991d52f9ae0118b49fe231ea328d0">also set the required referrerpolicy attribute</a>.</p> <h2 id="updated-packages-since-2025-11-04"><a class="toclink" href="#updated-packages-since-2025-11-04">Updated packages since 2025-11-04</a></h2> <ul> <li><a href="https://pypi.org/project/django-sitemaps/">django-sitemaps 2.0.2</a>: Uploaded a new release which includes a wheel build. Rebuilding the wheel all the time when creating new container images was getting annoying. The code itself is unchanged.</li> <li><a href="https://pypi.org/project/django_prune_uploads/">django-prune-uploads 0.3.1</a>: The package now supports pruning a storage backed by django-s3-storage efficiently. I have also looked at <a href="https://pypi.org/project/django-prune-media/">django-prune-media</a> but since the package uses the storage API instead of enumerating files using boto3 directly it&rsquo;s unusably slow for my use case.</li> <li><a href="https://pypi.org/project/feincms3-forms/">feincms3-forms 0.6</a>: Much better docs and a new way to reference <a href="https://github.com/feincms/feincms3-forms?tab=readme-ov-file#custom-templates-for-compound-fields">individual form fields in custom templates</a>.</li> <li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.11</a>: Switched from JSON paths to <code>jmespath</code> instances. Made the JSON model instance reference support easier and more fun to use. Added new ways of customizing the generated proxy model for individual JSON plugin instances.</li> <li><a href="https://pypi.org/project/form-designer/">form-designer 0.27.1</a>: Added support for the <a href="https://mosparo.io/">mosparo captcha</a> to the default list of field types.</li> <li><a href="https://pypi.org/project/asgi-plausible/">asgi-plausible 0.1.1</a>: No code change really, just added required dependencies to the package metadata.</li> <li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.23</a>: The package now ships a <code>OrderableTreeNode</code> base model which you can use when you want to order siblings manually. feincms3 already uses this base model for its pages model.</li> <li><a href="https://pypi.org/project/feincms3-data/">feincms3-data 0.10</a>: This is quite a big one. I discovered issues with the way <code>save_as_new</code> (to copy data) and <code>delete_missing</code> interacted. First the code was cleaned up to delete less data, and then to delete enough data. I&rsquo;m now somewhat confident that the code does what it should again.</li> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.22.3</a>: Started returning an empty string for an empty document instead of <code>&lt;p&gt;&lt;/p&gt;</code> also when using the frontend integration; previously, this transformation was only implemented when using at least the form if not the model field. Also, <code>&lt;ol&gt;</code> tags now have a <code>data-type</code> attribute since Chrome cannot case-sensitively match e.g. <code>type="a"</code> vs <code>type="A"</code> for lowercase or uppercase letters. I previously only tested the code in Firefox and there it worked nicely.</li> </ul>Thoughts about Django-based content management systemshttps://406.ch/writing/thoughts-about-django-based-content-management-systems/2025-11-05T12:00:00Z2025-11-05T12:00:00Z<h1 id="thoughts-about-django-based-content-management-systems"><a class="toclink" href="#thoughts-about-django-based-content-management-systems">Thoughts about Django-based content management systems</a></h1> <p>I have almost exclusively used Django for implementing content management systems (and other backends) since 2008.</p> <p>In this time, content management systems have come and gone. The big three systems many years back were <a href="https://www.django-cms.org/">django CMS</a>, <a href="https://github.com/stephenmcd/mezzanine">Mezzanine</a> and <a href="https://feinheit.ch">our</a> own <a href="https://406.ch/writing/the-future-of-feincms/">FeinCMS</a>.</p> <p>During all this time I have always kept an eye open for other CMS than our own but have steadily continued working in my small corner of the Django space. I think it&rsquo;s time to write down why I have been doing this all this time, for myself and possibly also for other interested parties.</p> <h2 id="why-not-use-wagtail-django-cms-or-any-of-those-alternatives"><a class="toclink" href="#why-not-use-wagtail-django-cms-or-any-of-those-alternatives">Why not use Wagtail, django CMS or any of those alternatives?</a></h2> <p>Let&rsquo;s start with the big one. Why not use Wagtail?</p> <p>The Django administration interface is actually great. Even though some people say that it should be treated as a tool for developers only, recent improvements to the accessibility and the general usability suggest otherwise. I have written more about my views on this in <a href="https://406.ch/writing/the-django-admin-is-a-cms/">The Django admin is a CMS</a>. Using and building on top of the Django admin is a great way to immediately profit from all current and future improvements without having to reimplement anything.</p> <p>I don&rsquo;t want to have to reimplement Django&rsquo;s features, I want to add what I need on top.</p> <h2 id="faster-updates"><a class="toclink" href="#faster-updates">Faster updates</a></h2> <p>Everyone implementing and maintaining other CMS is doing a great job and I don&rsquo;t want to throw any shade. I still feel that it&rsquo;s important to point out that systems can make it hard to adopt new Django versions on release day:</p> <ul> <li>The update cycle of many large apps using Django lag behind Django. Wagtail declares an <a href="https://github.com/wagtail/wagtail/discussions/12574">upper version boundary for Django</a> which makes it hard to adopt Django versions faster than Wagtail releases updates.</li> <li>Some django CMS components such as <a href="https://github.com/django-cms/django-filer">django-filer</a> have lagged behind in the past. Looking at the project&rsquo;s CI matrix and activity suggests that this is not the case anymore. That said, a <a href="https://406.ch/writing/django-cabinet-a-media-library-for-django/">simpler alternative exists</a>.</li> </ul> <p>These larger systems have many more (very talented) people working on them. I&rsquo;m not saying I&rsquo;m doing a better job. I&rsquo;m only pointing out that I&rsquo;m following a different philosophy where I&rsquo;m <a href="https://406.ch/writing/run-less-code-in-production-or-youll-end-up-paying-the-price-later/">conservative about running code in production</a> and I&rsquo;d rather <a href="https://406.ch/writing/low-maintenance-software/">have less features when the price is a lot of maintenance later</a>. I&rsquo;m always thinking about long term maintenance. I really don&rsquo;t want to maintain some of these larger projects, or even parts of them. So I&rsquo;d rather not adopt them for projects which hopefully will be developed and maintained for a long time to come. By the way: This experience has been earned the hard way.</p> <h2 id="the-rule-of-least-power"><a class="toclink" href="#the-rule-of-least-power">The rule of least power</a></h2> <p>From <a href="https://en.wikipedia.org/wiki/Rule_of_least_power">Wikipedia</a>:</p> <blockquote> <p>In programming, the rule of least power is a design principle that &ldquo;suggests choosing the least powerful [computer] language suitable for a given purpose&rdquo;. Stated alternatively, given a choice among computer languages, classes of which range from descriptive (or declarative) to procedural, the less procedural, more descriptive the language one chooses, the more one can do with the data stored in that language.</p> </blockquote> <p>Django itself already provides lots and lots of power. I&rsquo;d argue that a very powerful platform on top of Django may be too much of a good thing. I&rsquo;d rather keep it simple and stupid.</p> <h2 id="editing-heterogenous-collections-of-content"><a class="toclink" href="#editing-heterogenous-collections-of-content">Editing heterogenous collections of content</a></h2> <p>Django admin&rsquo;s inlines are great, but they are not sufficient for building a CMS. You need something to manage different types. django-content-editor does that and has done that since 2009.</p> <p><a href="https://torchbox.com/blog/rich-text-fields-and-faster-horses/">When Wagtail introduced the StreamField in 2015</a> it was definitely a great update to an already great CMS but it wasn&rsquo;t a new idea generally and not a new thing in Django land. They didn&rsquo;t say it was and <a href="https://406.ch/writing/i-just-learned-about-wagtail-s-streamfield/">welcomed the fact that they also started using a better way to structure content</a>.</p> <p>Structured content is great. Putting everything into one large rich text area isn&rsquo;t what I want. Django&rsquo;s ORM and admin interface are great for actually modelling the data in a reusable way. And when you need more flexibility than what&rsquo;s offered by Django&rsquo;s forms, the community offers many projects extending the admin. These days, I really like working with the <a href="https://406.ch/writing/django-json-schema-editor/">django-json-schema-editor</a> component; I even reference other model instances in the database and let the JSON editor handle the referential integrity transparently for me (so that referenced model instances do not silently disappear).</p> <h2 id="more-reading"><a class="toclink" href="#more-reading">More reading</a></h2> <p><a href="https://406.ch/writing/the-future-of-feincms/">The future of FeinCMS</a> and the <a href="https://406.ch/writing/category-feincms/">feincms category</a> may be interesting. Also, I&rsquo;d love to talk about these thoughts, either by email or on Mastodon.</p>Weeknotes (2025 week 45)https://406.ch/writing/weeknotes-2025-week-45/2025-11-04T12:00:00Z2025-11-04T12:00:00Z<h1 id="weeknotes-2025-week-45"><a class="toclink" href="#weeknotes-2025-week-45">Weeknotes (2025 week 45)</a></h1> <h2 id="autumn-is-nice"><a class="toclink" href="#autumn-is-nice">Autumn is nice</a></h2> <p>I love walking through the forest with all the colors and the rustling when you walk through the leaves on the ground.</p> <h2 id="updated-packages-since-2025-10-23"><a class="toclink" href="#updated-packages-since-2025-10-23">Updated packages since 2025-10-23</a></h2> <ul> <li><a href="https://pypi.org/project/feincms3/">feincms3 5.4.3</a>: Small fix for the YouTube IFRAME; it seems that the <code>referrerpolicy</code> attribute is now necessary for the embed to work everywhere.</li> <li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.8.2</a>: Allowed forwarding more options to the prose editor component; specifically, not just <code>extensions</code> but also the undocumented <code>js_modules</code> entry. This means that custom extensions are now also supported inside the JSON editor component.</li> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.20</a>: I reworked the menu extension to be customizable more easily (you can now specify <a href="https://django-prose-editor.readthedocs.io/en/latest/menu.html#defining-button-groups-and-dropdowns">button groups and dropdowns</a> directly without using JavaScript code) and I also extended the <a href="https://django-prose-editor.readthedocs.io/en/latest/nodeclass.html"><code>NodeClass</code> extension</a> to allow assigning predefined CSS classes not only to nodes but also to marks.</li> </ul>Weeknotes (2025 week 43)https://406.ch/writing/weeknotes-2025-week-43/2025-10-23T12:00:00Z2025-10-23T12:00:00Z<h1 id="weeknotes-2025-week-43"><a class="toclink" href="#weeknotes-2025-week-43">Weeknotes (2025 week 43)</a></h1> <p>I published the last weeknotes entry in the first half of September.</p> <h2 id="drama-in-oss"><a class="toclink" href="#drama-in-oss">Drama in OSS</a></h2> <p>I have been following the Ruby gems debacle a bit. Initially at <a href="https://feinheit.ch/">Feinheit</a> we used our own PHP-based framework <a href="https://github.com/matthiask/swisdk2">swisdk2</a> to build websites. This obviously didn&rsquo;t scale and I was very annoyed with PHP, so I was looking for alternatives.</p> <p>I remember comparing Ruby on Rails and Django, and decided to switch from PHP/swisdk2 to Python/Django for two reasons: The automatically generated admin interface and the fact that Ruby source code just had too much punctuation characters for my taste. It&rsquo;s a very whimsical reason and I do not put any weight on that. That being said, given how some of the exponents in Ruby/Rails land behave I&rsquo;m very very glad to have chosen Python and Django. While not everything is perfect (it never is) at least those communities agree that trying to behave nicely to each other is something to be cheered and not something to be sneered at.</p> <h2 id="copilot"><a class="toclink" href="#copilot">Copilot</a></h2> <p>I assigned some GitHub issues to Copilot. The result wasn&rsquo;t very useful. I don&rsquo;t know if I want to repeat it, local tools work fine for when I really need them.</p> <h2 id="python-and-django-compatibility"><a class="toclink" href="#python-and-django-compatibility">Python and Django compatibility</a></h2> <p>It&rsquo;s the time again to update the GitHub actions matrix and Trove identifiers. I do not like doing it. You can expect all maintained packages to be compatible with the latest and best versions, no upper bounds necessary. Man, if only AI could automate <em>those</em> tasks&hellip;</p> <h2 id="updated-packages-since-2025-09-10"><a class="toclink" href="#updated-packages-since-2025-09-10">Updated packages since 2025-09-10</a></h2> <ul> <li><a href="https://pypi.org/project/feincms3/">feincms3</a>: We use <code>hashlib.md5</code>, but not for security.</li> <li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.21.2</a>: django-tree-queries now bundles an admin interface for trees.</li> <li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 8.0.3</a>: Minor CSS fix so that the editor looks nicer with Django 6.0.</li> <li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.8.1</a>: Fixes for multiple JSON editors in the same window, better <code>JSONPlugin.__str__</code> default implementation.</li> <li><a href="https://pypi.org/project/django-cabinet/">django-cabinet 0.18.1</a>: Fix a minor bug around the selected folder handling. Invalid folder ID values could crash the backend under some circumstances.</li> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.18.5</a>: No large changes, mainly Tiptap updates.</li> </ul>My favorite Django packageshttps://406.ch/writing/my-favorite-django-packages/2025-10-22T12:00:00Z2025-10-22T12:00:00Z<h1 id="my-favorite-django-packages"><a class="toclink" href="#my-favorite-django-packages">My favorite Django packages</a></h1> <p>Inspired by other posts I also wanted to write up a list of my favorite Django packages. Since I&rsquo;ve been working in this space for so long and since I&rsquo;m maintaining quite a large list of packages I worry a bit about tooting my own horn too much here; that said, the reasons for choosing some packages hopefully speak for themselves.</p> <p>Also, I&rsquo;m sure I&rsquo;m forgetting many many packages here. Sorry for that in advance.</p> <h2 id="core-django"><a class="toclink" href="#core-django">Core Django</a></h2> <ul> <li><a href="https://pypi.org/project/speckenv/">speckenv</a>: Loads environment variables from <code>.env</code> and automatically converts them to their Python equivalent if <a href="https://docs.python.org/3/library/ast.html#ast.literal_eval"><code>ast.literal_eval</code></a> understands it. Also contains implementations for loading database, cache, email and storage configuration from environment variables (similar to dj-database-url). I added this functionality to speckenv when some of the available environment configuration apps&rsquo; maintenance state was somewhat questionable.</li> <li><a href="https://pypi.org/project/django-cors-headers/">django-cors-headers</a>: CORS header support for Django. This would be a nice addition to Django itself.</li> <li><a href="https://pypi.org/project/sentry-sdk/">sentry-sdk</a>: I&rsquo;m using Sentry in almost all projects.</li> <li><a href="https://pypi.org/project/django-template-partials/">django-template-partials</a>: Template partials for Django! This has been added to the upcoming 6.0 release of Django. While the Django template language has always been evolving and improving, this feels like the first larger step in a long time. As I and others have said, the combination of <a href="https://htmx.org/">htmx</a> and django-template-partials is really really nice. It isn&rsquo;t surprising at all that htmx is so well loved in the Django community.</li> <li><a href="https://pypi.org/project/django-s3-storage/">django-s3-storage</a>: Yes, django-storages exist, but I prefer django-s3-storage because of its focus and simplicity.</li> <li><a href="https://pypi.org/project/django-imagefield/">django-imagefield</a>: Image field which validates images deeply, and supports pre-rendering thumbnails etc. I understand why Django only superficially validates uploaded images because of the danger of denial of service attacks, but I&rsquo;d rather not have files on the sites I manage which the great <a href="https://pillow.readthedocs.io/">Pillow</a> library doesn&rsquo;t support.</li> <li><a href="https://pypi.org/project/psycopg/">psycopg</a>: Whenever I can I work with PostgreSQL, and psycopg is how I interface with the database.</li> </ul> <h2 id="data-structures"><a class="toclink" href="#data-structures">Data structures</a></h2> <ul> <li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries</a>: My favorite way to work with trees except when talking about real trees.</li> <li><a href="https://pypi.org/project/django-translated-fields/">django-translated-fields</a>: My preferred way to do model-level internationalization.</li> </ul> <h2 id="cms-building"><a class="toclink" href="#cms-building">CMS building</a></h2> <p>I have been working on <a href="https://github.com/feincms/feincms/">FeinCMS</a> since 2009. So, it shouldn&rsquo;t surprise anyone that this is still my favorite way to build CMS on top of Django. I like that it&rsquo;s basically a thin layer on top of Django&rsquo;s administration interface and doesn&rsquo;t want to take over the whole admin interface or even the whole website.</p> <ul> <li><a href="https://pypi.org/project/feincms3/">feincms3</a>: The modern, focussed foundation replacing FeinCMS.</li> <li><a href="https://pypi.org/project/django-content-editor/">django-content-editor</a>: The admin interface extension providing core components to build content editing interfaces.</li> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor</a>: A nice HTML editor by yours truly.</li> <li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor</a>: JSON schema-based editing using <a href="https://www.npmjs.com/package/@json-editor/json-editor">@json-editor/json-editor</a>.</li> </ul> <h2 id="working-with-external-content"><a class="toclink" href="#working-with-external-content">Working with external content</a></h2> <ul> <li><a href="https://pypi.org/project/micawber/">micawber</a>: Micawber is my favorite tool to embed third party content (YouTube, Vimeo, whatever) when feincms3&rsquo;s own very limited embedding functionality is insufficient.</li> <li><a href="https://pypi.org/project/feincms3-cookiecontrol/">feincms3-cookiecontrol</a>: Everyone likes cookie banner (or not). feincms3-cookiecontrol implements not just an informative cookie banner, but actually supports not embedding tracking scripts and third party content unless the user consented, either a blanket or a per-provider consent for embedded media. It does NOT support the very annoying interface where you have to deselect each tracking service individually.</li> </ul> <h2 id="pdf-generation"><a class="toclink" href="#pdf-generation">PDF generation</a></h2> <ul> <li><a href="https://pypi.org/project/reportlab/">Reportlab</a>: Reportlab is nice if you need fine-grained control over PDF generation. Reportlab has created more than 10&lsquo;000 invoices for the company I work at, so I&rsquo;m definitely grateful for it :-)</li> <li><a href="https://weasyprint.org/">Weasyprint</a>: I have been using Weasyprint more and more for generating PDFs. Using HTML and CSS can be nicer than using Reportlab&rsquo;s Platypus module. Weasyprint doesn&rsquo;t instrument a webbrowser to produce PDFs but instead implements the rendering engine itself. It works really well.</li> </ul> <h2 id="testing-and-development"><a class="toclink" href="#testing-and-development">Testing and development</a></h2> <ul> <li>I actually do like unittest. I have started using <a href="https://docs.pytest.org/en/stable/">pytest</a> somewhat more often because using <code>-k keyword</code> to filter tests to run is great.</li> <li><a href="https://pypi.org/project/factory-boy/">factory-boy</a>: This package has always treated me well when creating data for tests.</li> <li><a href="https://playwright.dev/">playwright</a>: Playwright is the end-to-end test browser automation library I prefer.</li> </ul> <p>Last but not least, I really like <a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar</a>. So much, that I&rsquo;m even helping with the maintenance since 2016.</p> <h2 id="serving"><a class="toclink" href="#serving">Serving</a></h2> <p>We mostly use Kubernetes to serve websites these days. Inside the pods, I&rsquo;m working with the <a href="https://pypi.org/project/granian/">granian</a> RSGI/ASGI server and with <a href="https://pypi.org/project/blacknoise/">blacknoise</a> for serving static files.</p>LLMs are making me a better programmer...https://406.ch/writing/llms-are-making-me-a-better-programmer/2025-09-12T12:00:00Z2025-09-12T12:00:00Z<h1 id="llms-are-making-me-a-better-programmer"><a class="toclink" href="#llms-are-making-me-a-better-programmer">LLMs are making me a better programmer&hellip;</a></h1> <p>I&rsquo;m still undecided about LLMs for programming. Sometimes they are very useful, especially when working on a clearly stated problem within a delimited area. Cleaning the code up afterwards is painful and takes a long time though. Even for small changes I&rsquo;m unsure if using LLMs is a way to save (any) resources, be it time, water, energy or whatever.</p> <p>They do help me get started, and help me be more ambitious. That&rsquo;s not a new idea. <a href="https://simonwillison.net/2023/Mar/27/ai-enhanced-development/">Simon Willison wrote a post about this in 2023</a> and the more I think about it or work with AI the more I think it&rsquo;s a good way to look at it.</p> <p>A recent example which comes to mind is writing end to end tests. I can&rsquo;t say I had a love-hate relationship with end to end testing, it was mostly a hate-hate relationship. I hate writing them because it&rsquo;s so tedious and I hate debugging them because of all the timing issues and the general flakyness of end to end testing. And I especially hate the fact that those tests break all the time when changing the code, even when changes are mostly unrelated.</p> <p>When I discovered that I could just use Claude Code to write those end to end tests I was ecstatic. Finally a way to add relevant tests to some of my open source projects without having to do all this annoying work myself! Unfortunately, I quickly discovered that Claude Code decided (ha!) it&rsquo;s more important to make tests pass than actually exercising the functionality in question. When some HTML/JavaScript widget wouldn&rsquo;t initialize, why not just manipulate <code>innerHTML</code> so that the DOM looks as if the JavaScript actually ran? Of course, that&rsquo;s a completely useless test. The amount of prodding and instructing the LLM agent required to stop adding workarounds and fallbacks everywhere was mindboggling. Also, since tests are also code which has to be maintained in the future, does generating a whole lot of code actually help or not? Of course, the amount of code involved wasn&rsquo;t exactly a big help when I really had to dig into the code to debug a gnarly issue, and the way the test was written didn&rsquo;t exactly help!</p> <p>I didn&rsquo;t want to go back to the previous state of things when I had only backend tests though, so I had to find a better way.</p> <h2 id="playwright-codegen-to-the-rescue"><a class="toclink" href="#playwright-codegen-to-the-rescue">Playwright codegen to the rescue</a></h2> <p>I already had some experience with <a href="https://playwright.dev/docs/codegen-intro">Playwright codegen</a>, having used it for testing some complex onboarding code for a client project I worked on a few years back, so I was already aware of the fact that I could run the browser, click through the interface myself, and playwright would actually generate some of the required Python code for the test itself.</p> <p>This worked fine for a project, but what about libraries? There, I generally do not have a full project ready to be used with <code>./manage.py runserver</code> and Playwright. So, I needed a different solution: Running Playwright from inside a test!</p> <p>If your test uses the <a href="https://docs.djangoproject.com/en/5.2/topics/testing/tools/#django.test.LiveServerTestCase"><code>LiveServerTestCase</code></a> all you have to do is insert the following lines into the body of your test, directly after creating the necessary data in the database (using fixtures, or probably better yet using something like <a href="https://pypi.org/project/factory-boy/">factory-boy</a>):</p> <div class="chl"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">subprocess</span> <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Live server URL: </span><span class="si">{</span><span class="n">live_server</span><span class="o">.</span><span class="n">url</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">Popen</span><span class="p">([</span><span class="s2">&quot;playwright&quot;</span><span class="p">,</span> <span class="s2">&quot;codegen&quot;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">live_server_url</span><span class="si">}</span><span class="s2">/admin/&quot;</span><span class="p">])</span> <span class="nb">input</span><span class="p">(</span><span class="s2">&quot;Press Enter when done with codegen...&quot;</span><span class="p">)</span> </code></pre></div> <p>Or of course the equivalent invocation using <code>live_server.url</code> when using the <code>live_server</code> fixture from <a href="https://pytest-django.readthedocs.io/en/latest/helpers.html#live-server">pytest-django</a>.</p> <p>Of course Tim <a href="https://mastodon.social/@CodenameTim/115096138737981083">pointed me towards <code>page.pause()</code></a>. I didn&rsquo;t know about it; I think it&rsquo;s even better than what I discovered, so I&rsquo;m probably going to use that one instead. I still think writing down the discovery process makes sense.</p> <p>So now, when <code>LiveServerTestCase</code> is already set up and I already have a sync Playwright context lying around, I can just do:</p> <div class="chl"><pre><span></span><code><span class="n">page</span> <span class="o">=</span> <span class="n">context</span><span class="o">.</span><span class="n">new_page</span><span class="p">()</span> <span class="n">page</span><span class="o">.</span><span class="n">pause</span><span class="p">()</span> </code></pre></div> <h2 id="tldr"><a class="toclink" href="#tldr">TLDR</a></h2> <p>Claude Code helped getting me to get off the ground with adding end to end tests to my projects. Now, my tests are better because &ndash; at least for now &ndash; I&rsquo;m not using AI tools anymore.</p>Weeknotes (2025 week 37)https://406.ch/writing/weeknotes-2025-week-37/2025-09-10T12:00:00Z2025-09-10T12:00:00Z<h1 id="weeknotes-2025-week-37"><a class="toclink" href="#weeknotes-2025-week-37">Weeknotes (2025 week 37)</a></h1> <p>I&rsquo;m having a slow week after the last wisdom tooth extraction. Finally! I&rsquo;m slowly recuperating from that.</p> <p>I&rsquo;m trying to split up the blog posts a bit and writing more standalone pieces instead of putting everything into weeknotes. Publishing more focussed pieces sounds like a good thing and should also help me with finding my own writing later.</p> <h2 id="releases"><a class="toclink" href="#releases">Releases</a></h2> <ul> <li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 8.0.2</a>: I fixed the ordering calculation in the cloning functionality; the tests are a bit too forgiving for my taste now but I just can&rsquo;t figure out why the gap for inserting cloned items is sometimes larger than it should be. It doesn&rsquo;t matter though, since ordering values do not have any significance, they only have to provide a definite ordering for content items.</li> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.18.2</a>: Cleaned up the documentation after the 0.18 cleanup (where automatic dependency management has been removed), fixed table styles when using dark mode.</li> </ul>Weeknotes (2025 week 35)https://406.ch/writing/weeknotes-2025-week-35/2025-08-29T12:00:00Z2025-08-29T12:00:00Z<h1 id="weeknotes-2025-week-35"><a class="toclink" href="#weeknotes-2025-week-35">Weeknotes (2025 week 35)</a></h1> <p>Summer was and is nice. The hot days seem to be over (for now), but in the last years summer hasn&rsquo;t really left until the end of September, so we&rsquo;ll see. I personally like the warm weather but I really hoped that our leaders were smarter. The <a href="https://406.ch/writing/category-climate/">climate emergency</a> could be seen from far away. The pigheadedness is hard to stomach. And of course it&rsquo;s not the only problem we&rsquo;re facing as humanity at all.</p> <h2 id="releases"><a class="toclink" href="#releases">Releases</a></h2> <p>I did some longer-form writing about two of the releases here: <a href="https://406.ch/writing/menu-improvements-in-django-prose-editor/">Menu improvements in django-prose-editor</a> and <a href="https://406.ch/writing/django-content-editor-cloning/">django-content-editor now supports cloning of content</a></p> <ul> <li><a href="https://pypi.org/project/django-debug-toolbar/">django-debug-toolbar 6.0</a>: We have released a new version of the toolbar which supports persisting debugging data to the database. This is especially useful when using ASGI, because we cannot use threadlocal storage for this data then.</li> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.18</a>: Reworked the menu system to support dropdowns, not just button groups. Added a custom <code>TextClass</code> extension which allows adding classes to spans and a <code>NodeClass</code> extension which allows adding classes to nodes. Tiptap supports adding arbitrary styles, I&rsquo;d rather limit this a bit more and only offer predefined CSS classes.</li> <li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 8.0</a>: Added support for cloning content. Made the region tabs stick to the top of the browser window.</li> <li><a href="https://pypi.org/project/django-cabinet/">django-cabinet 0.18</a>: Improved the folder deletion interface slightly.</li> <li><a href="https://pypi.org/project/django-mptt/">django-mptt 0.18</a>: I still seem to be maintaining the project even though I officially marked the project as unmaintained <a href="https://github.com/django-mptt/django-mptt/commit/6f6c1c485f3adc1d579f8d22e0279ce1d52334f6">more than 4 years ago</a>.</li> </ul>django-content-editor now supports cloning of contenthttps://406.ch/writing/django-content-editor-cloning/2025-08-27T12:00:00Z2025-08-27T12:00:00Z<h1 id="django-content-editor-now-supports-cloning-of-content"><a class="toclink" href="#django-content-editor-now-supports-cloning-of-content">django-content-editor now supports cloning of content</a></h1> <h2 id="what-is-the-content-editor"><a class="toclink" href="#what-is-the-content-editor">What is the content editor?</a></h2> <p>Django’s builtin admin application provides a really good and usable administration interface for creating and updating content. <code>django-content-editor</code> extends Django&rsquo;s inlines mechanism with an interface and tools for managing and rendering heterogenous collections of content as are often necessary for content management systems.</p> <p><a href="https://feinheit.ch">We</a> are using <a href="https://pypi.org/project/django-content-editor/">django-content-editor</a> in basically all projects, as a part of <a href="https://pypi.org/project/feincms3/">feincms3</a>. The content editor is used not only for building page content, but also for blog entries, for <a href="https://github.com/feincms/feincms3-forms">building multi-step intelligent form wizards</a>, for <a href="https://feinheit.ch/projekte/finance-mission-world/">learning units</a> and even to digitize teaching materials for schools, including static and interactive content.</p> <p>The great thing about it is that it enables us to edit complex content inside Django&rsquo;s administration interface without trying to replace it with a completely separate interface, as some other more well-known Django-based CMS want to do.</p> <h2 id="cloning-content"><a class="toclink" href="#cloning-content">Cloning content</a></h2> <p>The complexity of managed content has grown a bit, especially since we introduced support for <a href="https://406.ch/writing/django-content-editor-now-supports-nested-sections/">nesting sections</a>. Teaching materials are often available in several learning levels, with only minor differences between them. Unfortunately, the differences aren&rsquo;t purely additive: It&rsquo;s not the case that higher levels just have more materials available. Otherwise, we&rsquo;d probably have used a level on content items to hide content which shouldn&rsquo;t be shown to students. Content is sometimes totally different. Because of this we&rsquo;re using content editor&rsquo;s <strong>regions</strong> for the learning level, one region per level.</p> <p>Even then, the basic structure is often the same and building that manually for all levels is annoying at best. That&rsquo;s why I finally got the occasion to add support for cloning content between regions to the editor.</p> <p>Of course, cloning should also take the other features into account and allow selecting sections as a whole instead of having to select individual items. Here&rsquo;s a screenshot of the current interface:</p> <p><img alt="Screenshot showing the content cloning interface" src="/assets/20250827-content-editor-cloning.png" /></p> <h2 id="closing-thoughts"><a class="toclink" href="#closing-thoughts">Closing thoughts</a></h2> <p>I&rsquo;m still really happy with the content editor; I wish the Django admin would look a little bit nicer because then people would probably be more encouraged to actually learn how powerful it is. The first impression is unfortunately that it looks old and a bit too technical, but in my experience working with many many customers it&rsquo;s not really the case. Most people are immediately able to work with it and find the interface well structured and appreciate the no bullshit attitude, because working with it really is efficient.</p>Menu improvements in django-prose-editorhttps://406.ch/writing/menu-improvements-in-django-prose-editor/2025-08-23T12:00:00Z2025-08-23T12:00:00Z<h1 id="menu-improvements-in-django-prose-editor"><a class="toclink" href="#menu-improvements-in-django-prose-editor">Menu improvements in django-prose-editor</a></h1> <p>I have repeatedly mentioned the <a href="https://pypi.org/project/django-prose-editor/">django-prose-editor</a> project in my <a href="https://406.ch/writing/category-weeknotes/">weeknotes</a> but I haven&rsquo;t written a proper post about it since <a href="https://406.ch/writing/rebuilding-django-prose-editor-from-the-ground-up/">rebuilding it on top of Tiptap at the end of 2024</a>.</p> <p>Much has happened in the meantime. A lot of work went into the menu system (as alluded to in the title of this post), but by no means does that cover all the work. As always, the <a href="https://django-prose-editor.readthedocs.io/en/latest/changelog.html">CHANGELOG</a> is the authoritative source.</p> <p><strong>0.11</strong> introduced HTML sanitization which only allows HTML tags and attributes which can be added through the editor interface. Previously, we used <a href="https://nh3.readthedocs.io/">nh3</a> to clean up HTML and protect against XSS, but now we can be much more strict and use a restrictive allowlist.</p> <p>We also switched to using <a href="https://406.ch/writing/django-javascript-modules-and-importmaps/">ES modules and importmaps</a> in the browser.</p> <p>Last but not least 0.11 also introduced end-to-end testing using <a href="https://playwright.dev/">Playwright</a>.</p> <p>The main feature in <strong>0.12</strong> was the switch to Tiptap 3.0 which fixed problems with shared extension storage when using several prose editors on the same page.</p> <p>In <strong>0.13</strong> we switched from <a href="https://esbuild.github.io/">esbuild</a> to <a href="https://lib.rsbuild.dev/">rslib</a>. Esbuild&rsquo;s configuration is nicer to look at, but rslib is built on the very powerful <a href="https://rspack.dev/">rspack</a> which I&rsquo;m using everywhere.</p> <p>In <strong>0.14</strong>, <strong>0.15</strong> and <strong>0.16</strong> the <code>Menu</code> extension was made more reusable and the way extension can register their own menu items was reworked.</p> <p>The upcoming <strong>0.17</strong> release (alpha releases are available and I&rsquo;m using them in production right now!) is a larger release again and introduces a completely reworked menu system. The menu now not only supports button groups and dialogs but also dropdowns directly in the toolbar. This allows for example showing a dropdown for block types:</p> <p><img alt="Screenshot showing prose editor dropdowns" src="/assets/20250823-prose-editor-dropdowns.png" /></p> <p>The styles are the same as those used in the editor interface.</p> <p>The same interface can not only be used for HTML elements, but also for HTML classes. Tiptap has a <a href="https://tiptap.dev/docs/editor/extensions/functionality/text-style-kit">TextStyle</a> extension which allows using inline styles; I&rsquo;d rather have a more restricted way of styling spans, and the prose editor <code>TextClass</code> extension does just that: It allows applying a list of predefined CSS classes to <code>&lt;span&gt;</code> elements. Of course the dropdown also shows the resulting presentation if you provide the necessary CSS to the admin interface.</p>Weeknotes (2025 week 27)https://406.ch/writing/weeknotes-2025-week-27/2025-07-05T12:00:00Z2025-07-05T12:00:00Z<h1 id="weeknotes-2025-week-27"><a class="toclink" href="#weeknotes-2025-week-27">Weeknotes (2025 week 27)</a></h1> <p>I have again missed a few weeks, so the releases section will be longer than usual since it covers six weeks.</p> <h2 id="django-prose-editor"><a class="toclink" href="#django-prose-editor">django-prose-editor</a></h2> <p>I have totally restructured the documentation to make it clearer. The <a href="https://django-prose-editor.readthedocs.io/en/latest/configuration.html">configuration chapter</a> is shorter and more focussed, and the <a href="https://django-prose-editor.readthedocs.io/en/latest/custom_extensions.html">custom extensions chapter</a> actually shows all required parts now.</p> <p>The most visible change is probably the refactored menu system. Extensions now have an <code>addMenuItems</code> method where they can add their own buttons to the menu bar. I wanted to do this for a long time but have only just this week found a way to achieve this which I actually like.</p> <p>I&rsquo;ve reported a bug to Tiptap where a <code>.can()</code> chain always succeeded even though the actual operation could fail (<a href="https://github.com/ueberdosis/tiptap/issues/6306">#6306</a>).</p> <p>Finally, I have also switched from <a href="https://esbuild.github.io/">esbuild</a> to <a href="https://rslib.rs/">rslib</a>; I&rsquo;m a heavy user of rspack anyway and am more at home with its configuration.</p> <h2 id="django-content-editor"><a class="toclink" href="#django-content-editor">django-content-editor</a></h2> <p>The 7.4 release mostly contains minor changes, one new feature is the <code>content_editor.admin.RefinedModelAdmin</code> class. It includes tweaks to Django&rsquo;s standard behavior such as supporting a <code>Ctrl-S</code> shortcut for the &ldquo;Save and continue editing&rdquo; functionality and an additional warning when people want to delete inlines and instead delete the whole object. This seems to happen often even though people are shown the full list of objects which will be deleted.</p> <h2 id="releases"><a class="toclink" href="#releases">Releases</a></h2> <ul> <li><a href="https://pypi.org/project/django-prose-editor/">django-prose-editor 0.15</a>: See above</li> <li><a href="https://pypi.org/project/django-content-editor/">django-content-editor 7.4.1</a>: See above.</li> <li><a href="https://pypi.org/project/django-json-schema-editor/">django-json-schema-editor 0.5.1</a>: Now supports customizing the prose editor configuration (when using <code>format: "prose"</code>) and also includes validation support for foreign key references in the JSON data.</li> <li><a href="https://pypi.org/project/html-sanitizer/">html-sanitizer 2.6</a>: The sanitizer started crashing when used with <code>lxml&gt;=6</code> when being fed strings with control characters inside.</li> <li><a href="https://pypi.org/project/django_recent_objects/">django-recent-objects 0.1.1</a>: Changed the code to use <code>UNION ALL</code> instead of <code>UNION</code> when determining which objects to fetch from all tables.</li> <li><a href="https://pypi.org/project/feincms3/">feincms3 5.4.1</a>: Added experimental support for rendering sections. Sections can be nested, so they are more powerful than subregions. Also, added warnings when registering plugin proxies for rendering <em>and</em> fetching, since that will mostly likely lead to duplicated objects in the rendered output.</li> <li><a href="https://pypi.org/project/django-tree-queries/">django-tree-queries 0.20</a>: Added <code>tree_info</code> and <code>recursetree</code> template tags. Optimized the performance by avoiding the rank table if easily possible. Added stronger recommendations to pre-filter the table using <code>.tree_filter()</code> or <code>.tree_exclude()</code> when working with small subsets of large datasets.</li> <li><a href="https://pypi.org/project/django-ckeditor/">django-ckeditor 6.7.3</a>: Added a trove identifeir for recent Django versions. It still works fine, but it&rsquo;s deprecated and shouldn&rsquo;t be used since it still uses the unmaintained CKEditor 4 line (since we do not ship the commercial LTS version).</li> <li><a href="https://pypi.org/project/feincms3-cookiecontrol/">feincms3-cookiecontrol 1.6.1</a>: Golfed the generated CSS and JavaScript bundle down to below 4000 bytes again, including the YouTube/Vimeo/etc. wrapper which only loads external content when users consent.</li> </ul>Preserving referential integrity with JSON fields and Djangohttps://406.ch/writing/preserving-referential-integrity-with-json-fields-and-django/2025-06-04T12:00:00Z2025-06-04T12:00:00Z<h1 id="preserving-referential-integrity-with-json-fields-and-django"><a class="toclink" href="#preserving-referential-integrity-with-json-fields-and-django">Preserving referential integrity with JSON fields and Django</a></h1> <h2 id="motivation"><a class="toclink" href="#motivation">Motivation</a></h2> <p>The great thing about using <a href="https://feincms3.readthedocs.io/">feincms3</a> and <a href="https://django-content-editor.readthedocs.io/">django-content-editor</a> is that CMS plugins are Django models &ndash; if using them you immediately have access to the power of Django&rsquo;s ORM and Django&rsquo;s administration interface.</p> <p>However, using one model per content type can be limiting on larger sites. Because of this <a href="https://feinheit.ch/">we</a> like using JSON plugins with schemas for more fringe use cases or for places where we have richer data but do not want to write a separate Django app for it. This works well as long as you only work with text, numbers etc. but gets a bit ugly once you start referencing Django models because you never know if those objects are still around when actually using the data stored in those JSON fields.</p> <p>Django has a nice <a href="https://docs.djangoproject.com/en/5.2/ref/models/fields/#django.db.models.ForeignKey.on_delete"><code>on_delete=models.PROTECT</code></a> feature, but that of course only works when using real models. So, let&rsquo;s bridge this gap and allow using foreign key protection with data stored in JSON fields!</p> <h2 id="models"><a class="toclink" href="#models">Models</a></h2> <p>First, you have to start using the <a href="https://github.com/matthiask/django-json-schema-editor">django-json-schema-editor</a> and specifically its <code>JSONField</code> instead of the standard Django <code>JSONField</code>. The most important difference between those two is that the schema editor&rsquo;s field wants a JSON schema. So, for the sake of an example, let&rsquo;s assume that we have a model with images and a model with galleries. Note that we&rsquo;re omitting many of the fields actually making the interface nice such as titles etc.</p> <div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django.db</span><span class="w"> </span><span class="kn">import</span> <span class="n">models</span> <span class="kn">from</span><span class="w"> </span><span class="nn">django_json_schema_editor.fields</span><span class="w"> </span><span class="kn">import</span> <span class="n">JSONField</span> <span class="k">class</span><span class="w"> </span><span class="nc">Image</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span> <span class="n">image</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ImageField</span><span class="p">(</span><span class="o">...</span><span class="p">)</span> <span class="n">gallery_schema</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;object&quot;</span><span class="p">,</span> <span class="s2">&quot;properties&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&quot;caption&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;string&quot;</span><span class="p">},</span> <span class="s2">&quot;images&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;array&quot;</span><span class="p">,</span> <span class="s2">&quot;format&quot;</span><span class="p">:</span> <span class="s2">&quot;table&quot;</span><span class="p">,</span> <span class="s2">&quot;minItems&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="s2">&quot;items&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;string&quot;</span><span class="p">,</span> <span class="s2">&quot;format&quot;</span><span class="p">:</span> <span class="s2">&quot;foreign_key&quot;</span><span class="p">,</span> <span class="s2">&quot;options&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="c1"># raw_id_fields URL:</span> <span class="s2">&quot;url&quot;</span><span class="p">:</span> <span class="s2">&quot;/admin/myapp/image/?_popup=1&amp;_to_field=id&quot;</span><span class="p">,</span> <span class="p">},</span> <span class="p">},</span> <span class="p">},</span> <span class="p">},</span> <span class="p">}</span> <span class="k">class</span><span class="w"> </span><span class="nc">Gallery</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span> <span class="n">data</span> <span class="o">=</span> <span class="n">JSONField</span><span class="p">(</span><span class="n">schema</span><span class="o">=</span><span class="n">gallery_schema</span><span class="p">)</span> </code></pre></div> <p>Now, if we were to do it by hand, we&rsquo;d define a <code>through</code> model for a <code>ManyToManyField</code> linking galleries to images, and adding a <code>on_delete=models.PROTECT</code> foreign key to this through model&rsquo;s <code>image</code> foreign key and we would be updating this many to many table when the <code>Gallery</code> object changes. Since that&rsquo;s somewhat <a href="https://github.com/matthiask/django-json-schema-editor/blob/4bc1ab0cf44eda4c0e824f96f2bd08cd94832c1c/django_json_schema_editor/fields.py#L9-L47">boring but also tricky code</a> I have already written it (including unit tests of course) and all that&rsquo;s left to do is define the linking:</p> <div class="chl"><pre><span></span><code><span class="n">Gallery</span><span class="o">.</span><span class="n">register_data_reference</span><span class="p">(</span> <span class="c1"># The model we&#39;re referencing:</span> <span class="n">Image</span><span class="p">,</span> <span class="c1"># The name of the ManyToManyField:</span> <span class="n">name</span><span class="o">=</span><span class="s2">&quot;images&quot;</span><span class="p">,</span> <span class="c1"># The getter which returns a list of stringified primary key values or nothing:</span> <span class="n">getter</span><span class="o">=</span><span class="k">lambda</span> <span class="n">obj</span><span class="p">:</span> <span class="n">obj</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;images&quot;</span><span class="p">),</span> <span class="p">)</span> </code></pre></div> <p>Now, attempting to delete an image which is still used in a gallery somewhere will raise <a href="https://docs.djangoproject.com/en/5.2/ref/exceptions/#django.db.models.ProtectedError">ProtectedError</a> exceptions. That&rsquo;s what we wanted to achieve.</p> <h2 id="using-a-gallery-instance"><a class="toclink" href="#using-a-gallery-instance">Using a gallery instance</a></h2> <p>When you have a gallery instance you can now use the <code>images</code> field to fetch all images and use the order from the JSON data:</p> <div class="chl"><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">gallery_context</span><span class="p">(</span><span class="n">gallery</span><span class="p">):</span> <span class="n">images</span> <span class="o">=</span> <span class="p">{</span><span class="nb">str</span><span class="p">(</span><span class="n">image</span><span class="o">.</span><span class="n">pk</span><span class="p">):</span> <span class="n">image</span> <span class="k">for</span> <span class="n">image</span> <span class="ow">in</span> <span class="n">gallery</span><span class="o">.</span><span class="n">images</span><span class="o">.</span><span class="n">all</span><span class="p">()}</span> <span class="k">return</span> <span class="p">{</span> <span class="s2">&quot;caption&quot;</span><span class="p">:</span> <span class="n">gallery</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="s2">&quot;caption&quot;</span><span class="p">],</span> <span class="s2">&quot;images&quot;</span><span class="p">:</span> <span class="p">[</span><span class="n">images</span><span class="p">[</span><span class="n">pk</span><span class="p">]</span> <span class="k">for</span> <span class="n">pk</span> <span class="ow">in</span> <span class="n">gallery</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="s2">&quot;images&quot;</span><span class="p">]],</span> <span class="p">}</span> </code></pre></div> <h2 id="jsonpluginbase-and-jsonplugininline"><a class="toclink" href="#jsonpluginbase-and-jsonplugininline">JSONPluginBase and JSONPluginInline</a></h2> <p>I would generally do the instantiation of models slightly differently and use <code>django-json-schema-editor</code>&rsquo;s <code>JSONPluginBase</code> and <code>JSONPluginInline</code> which offer additional niceties such as streamlined JSON models with only one backing database table (using <a href="https://docs.djangoproject.com/en/5.2/topics/db/models/#proxy-models">proxy models</a>) and supporting not just showing the primary key of referenced model instances but also their <code>__str__</code> value.</p> <p>The example above would have to be changed to look more like this:</p> <div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">django_json_schema_editor</span><span class="w"> </span><span class="kn">import</span> <span class="n">JSONPluginBase</span> <span class="k">class</span><span class="w"> </span><span class="nc">JSONPlugin</span><span class="p">(</span><span class="n">JSONPluginBase</span><span class="p">,</span> <span class="o">...</span><span class="p">):</span> <span class="k">pass</span> <span class="n">JSONPlugin</span><span class="o">.</span><span class="n">register_data_reference</span><span class="p">(</span><span class="o">...</span><span class="p">)</span> <span class="n">Gallery</span> <span class="o">=</span> <span class="n">JSONPlugin</span><span class="o">.</span><span class="n">proxy</span><span class="p">(</span><span class="s2">&quot;gallery&quot;</span><span class="p">,</span> <span class="n">schema</span><span class="o">=</span><span class="n">gallery_schema</span><span class="p">)</span> </code></pre></div> <p>However, that&rsquo;s not documented yet so for now you unfortunately have to read the <a href="https://github.com/matthiask/django-json-schema-editor">code and the test suite</a>, sorry for that. It&rsquo;s used heavily in production though so if you start using it it won&rsquo;t suddenly start breaking in the future.</p>How I'm bundling frontend assets using Django and rspack these dayshttps://406.ch/writing/how-i-m-bundling-frontend-assets-using-django-and-rspack-these-days/2025-05-26T12:00:00Z2025-05-26T12:00:00Z<h1 id="how-im-bundling-frontend-assets-using-django-and-rspack-these-days"><a class="toclink" href="#how-im-bundling-frontend-assets-using-django-and-rspack-these-days">How I&rsquo;m bundling frontend assets using Django and rspack these days</a></h1> <p>I last wrote about configuring Django with bundlers in 2018: <a href="https://406.ch/writing/our-approach-to-configuring-django-webpack-and-manifeststaticfilesstorage/">Our approach to configuring Django, Webpack and ManifestStaticFilesStorage</a>. An update has been a long time coming. I wanted to write this down for a while already, but each time I started explaining how configuring rspack is actually nice I look at the files we&rsquo;re using and switch to writing about something else. This time I managed to get through &ndash; it&rsquo;s not that bad, I promise.</p> <p>This is quite a long post. A project where all of this can be seen in action is <a href="https://github.com/matthiask/traduire/">Traduire</a>, a platform for translating gettext catalogs. I announced it on the <a href="https://forum.djangoproject.com/t/traduire-a-platform-for-editing-gettext-translations-on-the-web/32687">Django forum</a>.</p> <h2 id="our-requirements"><a class="toclink" href="#our-requirements">Our requirements</a></h2> <p>The requirements were still basically the same:</p> <ul> <li>Hot module reloading during development</li> <li>A process which produces hashed filenames depending on their content so that we can use far-future expiry headers to cache assets in browsers</li> <li>While running Node.js in development is fine we do not want Node.js on the server (in the general case)</li> <li>We still want transpiling and bundling for now</li> </ul> <p>We have old projects using SASS. These days we&rsquo;re only using PostCSS (especially <a href="https://github.com/postcss/autoprefixer">autoprefixer</a> and maybe <a href="https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-nesting">postcss-nesting</a>. Rewriting everything is out of the question, so we needed a tool which handled all that as well.</p> <p>People in the frontend space seem to like tools like Vite or Next.js a lot. I have also looked at Parcel, esbuild, rsbuild and others. Either they didn&rsquo;t support our old projects, were too limited in scope (e.g. no HMR), too opinionated or I hit bugs or had questions about their maintenance. I&rsquo;m sure all of them are great for some people, and I don&rsquo;t intend to talk badly about any of them!</p> <p>In the end, the flexibility, speed and trustworthiness of <a href="https://rspack.dev/">rspack</a> won me over even though I have a love-hate relationship with the Webpack/rspack configuration. We already had a reusable library of configuration snippets for webpack though and moving that library over to rspack was straightforward.</p> <p>That being said, configuring rspack from scratch is no joke, that&rsquo;s why tools such as <a href="https://rsbuild.dev/">rsbuild</a> exist. If you already know Webpack well or really need the flexibility, going low level can be good.</p> <h2 id="high-level-project-structure"><a class="toclink" href="#high-level-project-structure">High-level project structure</a></h2> <p>The high-level overview is:</p> <ul> <li>Frontend assets live in their own folder, <code>frontend/</code>.</li> <li>We&rsquo;re using <a href="https://www.fabfile.org/">fabric</a> and <a href="https://rspack.dev/">rspack</a>, their configuration resides in the root folder of the project as does Django&rsquo;s <code>manage.py</code>.</li> <li>The frontend is transpiled and bundled directly into <code>static/</code> for production and into <code>tmp/</code> during development.</li> <li>We use the HTML plugin of rspack to emit snippets containing <code>&lt;link&gt;</code> and <code>&lt;script&gt;</code> tags. The HTML snippet can be included as-is, without any postprocessing.</li> <li><code>frontend/</code> or <code>frontend/static</code> is optionally added to <code>STATICFILES_DIRS</code> so that some of the files from the frontend can easily be referenced in <code>{% static %}</code> tags.</li> </ul> <p>During development:</p> <ul> <li>We use the dev server of rspack/node to handle <code>127.0.0.1:8000</code>. This server handles requests for frontend assets and the websocket for hot module reloading and proxies everything else to the Django backend running on a different random port.</li> </ul> <p>During deployment:</p> <ul> <li>The assets are compiled to <code>static/</code> and either rsynced to the server or added to the container separately from the standard <code>./manage.py collectstatic --noinput</code>.</li> </ul> <p>In production:</p> <ul> <li>Separate cache busting filenames from <code>ManifestStaticFilesStorage</code> and rspack allow us to set far-future expiry headers on all static assets.</li> <li>I&rsquo;m serving static assets from the same origin as the website itself. (rspack can be configured for different requirements!)</li> <li>I don&rsquo;t worry anymore about duplicating assets which are both referenced from frontend code and backend code. This doesn&rsquo;t affect many assets after all.</li> <li>The HTML snippet is loaded once only.</li> </ul> <h2 id="example-configuration"><a class="toclink" href="#example-configuration">Example configuration</a></h2> <p>Here&rsquo;s an example configuration which works well for us. What follows is the rspack configuration itself, building on our snippet library <code>rspack.library.js</code>. We mostly do not change anything in here except for the list of PostCSS plugins:</p> <p>rspack.config.js:</p> <div class="chl"><pre><span></span><code><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nx">env</span><span class="p">,</span><span class="w"> </span><span class="nx">argv</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">base</span><span class="p">,</span><span class="w"> </span><span class="nx">devServer</span><span class="p">,</span><span class="w"> </span><span class="nx">assetRule</span><span class="p">,</span><span class="w"> </span><span class="nx">postcssRule</span><span class="p">,</span><span class="w"> </span><span class="nx">swcWithPreactRule</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">=</span> <span class="w"> </span><span class="nx">require</span><span class="p">(</span><span class="s2">&quot;./rspack.library.js&quot;</span><span class="p">)(</span><span class="nx">argv</span><span class="p">.</span><span class="nx">mode</span><span class="w"> </span><span class="o">===</span><span class="w"> </span><span class="s2">&quot;production&quot;</span><span class="p">)</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="p">...</span><span class="nx">base</span><span class="p">,</span> <span class="w"> </span><span class="nx">devServer</span><span class="o">:</span><span class="w"> </span><span class="nx">devServer</span><span class="p">({</span><span class="w"> </span><span class="nx">backendPort</span><span class="o">:</span><span class="w"> </span><span class="nx">env</span><span class="p">.</span><span class="nx">backend</span><span class="w"> </span><span class="p">}),</span> <span class="w"> </span><span class="nx">module</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">rules</span><span class="o">:</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="nx">assetRule</span><span class="p">(),</span> <span class="w"> </span><span class="nx">postcssRule</span><span class="p">({</span> <span class="w"> </span><span class="nx">plugins</span><span class="o">:</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="s2">&quot;postcss-nesting&quot;</span><span class="p">,</span> <span class="w"> </span><span class="s2">&quot;autoprefixer&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">],</span> <span class="w"> </span><span class="p">}),</span> <span class="w"> </span><span class="nx">swcWithPreactRule</span><span class="p">(),</span> <span class="w"> </span><span class="p">],</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">}</span> <span class="p">}</span> </code></pre></div> <p>The default entry point is <code>main</code> and loads <code>frontend/main.js</code>. The rest of the JavaScript and styles are loaded from there.</p> <p>The HTML snippet loader works by adding <code>WEBPACK_ASSETS = BASE_DIR / "static"</code> to the Django settings and adding the following tags to the <code>&lt;head&gt;</code> of the website, most often in <code>base.html</code>:</p> <div class="chl"><pre><span></span><code><span class="cp">{%</span> <span class="k">load</span> <span class="nv">webpack_assets</span> <span class="cp">%}</span> <span class="cp">{%</span> <span class="k">webpack_assets</span> <span class="s1">&#39;main&#39;</span> <span class="cp">%}</span> </code></pre></div> <p>The corresponding template tag in <code>webpack_assets.py</code> follows:</p> <div class="chl"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">functools</span><span class="w"> </span><span class="kn">import</span> <span class="n">cache</span> <span class="kn">from</span><span class="w"> </span><span class="nn">django</span><span class="w"> </span><span class="kn">import</span> <span class="n">template</span> <span class="kn">from</span><span class="w"> </span><span class="nn">django.conf</span><span class="w"> </span><span class="kn">import</span> <span class="n">settings</span> <span class="kn">from</span><span class="w"> </span><span class="nn">django.utils.html</span><span class="w"> </span><span class="kn">import</span> <span class="n">mark_safe</span> <span class="n">register</span> <span class="o">=</span> <span class="n">template</span><span class="o">.</span><span class="n">Library</span><span class="p">()</span> <span class="k">def</span><span class="w"> </span><span class="nf">webpack_assets</span><span class="p">(</span><span class="n">entry</span><span class="p">):</span> <span class="n">path</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">BASE_DIR</span> <span class="o">/</span> <span class="p">(</span><span class="s2">&quot;tmp&quot;</span> <span class="k">if</span> <span class="n">settings</span><span class="o">.</span><span class="n">DEBUG</span> <span class="k">else</span> <span class="s2">&quot;static&quot;</span><span class="p">)</span> <span class="o">/</span> <span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">entry</span><span class="si">}</span><span class="s2">.html&quot;</span> <span class="k">return</span> <span class="n">mark_safe</span><span class="p">(</span><span class="n">path</span><span class="o">.</span><span class="n">read_text</span><span class="p">())</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">settings</span><span class="o">.</span><span class="n">DEBUG</span><span class="p">:</span> <span class="n">webpack_assets</span> <span class="o">=</span> <span class="n">cache</span><span class="p">(</span><span class="n">webpack_assets</span><span class="p">)</span> <span class="n">register</span><span class="o">.</span><span class="n">simple_tag</span><span class="p">(</span><span class="n">webpack_assets</span><span class="p">)</span> </code></pre></div> <p>Last but not least, the fabfile contains the following task definition:</p> <div class="chl"><pre><span></span><code><span class="nd">@task</span> <span class="k">def</span><span class="w"> </span><span class="nf">dev</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">host</span><span class="o">=</span><span class="s2">&quot;127.0.0.1&quot;</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">8000</span><span class="p">):</span> <span class="n">backend</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">randint</span><span class="p">(</span><span class="mi">50000</span><span class="p">,</span> <span class="mi">60000</span><span class="p">)</span> <span class="n">jobs</span> <span class="o">=</span> <span class="p">[</span> <span class="sa">f</span><span class="s2">&quot;.venv/bin/python manage.py runserver </span><span class="si">{</span><span class="n">backend</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&quot;HOST=</span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s2"> PORT=</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s2"> yarn run rspack serve --mode=development --env backend=</span><span class="si">{</span><span class="n">backend</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">,</span> <span class="p">]</span> <span class="c1"># Run these two jobs at the same time:</span> <span class="n">_concurrently</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">jobs</span><span class="p">)</span> </code></pre></div> <p>The fh-fablib repository contains the <a href="https://github.com/feinheit/fh-fablib/blob/8109a76b63b37d3433356fabb4469263f8b18d66/fh_fablib/__init__.py#L194-L214"><code>_concurrently</code></a> implementation we&rsquo;re using at this time.</p> <h2 id="the-library-which-enables-the-nice-configuration-above"><a class="toclink" href="#the-library-which-enables-the-nice-configuration-above">The library which enables the nice configuration above</a></h2> <p>Of course, the whole library of snippets has to be somewhere. The fabfile automatically updates the library when we release a new version, and the library is the same in all the dozens of projects we&rsquo;re working on. Here&rsquo;s the current version of <code>rspack.library.js</code>:</p> <div class="chl"><pre><span></span><code><span class="kd">const</span><span class="w"> </span><span class="nx">path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">require</span><span class="p">(</span><span class="s2">&quot;node:path&quot;</span><span class="p">)</span> <span class="kd">const</span><span class="w"> </span><span class="nx">HtmlWebpackPlugin</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">require</span><span class="p">(</span><span class="s2">&quot;html-webpack-plugin&quot;</span><span class="p">)</span> <span class="kd">const</span><span class="w"> </span><span class="nx">rspack</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">require</span><span class="p">(</span><span class="s2">&quot;@rspack/core&quot;</span><span class="p">)</span> <span class="kd">const</span><span class="w"> </span><span class="nx">assert</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">require</span><span class="p">(</span><span class="s2">&quot;node:assert/strict&quot;</span><span class="p">)</span> <span class="kd">const</span><span class="w"> </span><span class="nx">semver</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">require</span><span class="p">(</span><span class="s2">&quot;semver&quot;</span><span class="p">)</span> <span class="nx">assert</span><span class="p">.</span><span class="nx">ok</span><span class="p">(</span><span class="nx">semver</span><span class="p">.</span><span class="nx">satisfies</span><span class="p">(</span><span class="nx">rspack</span><span class="p">.</span><span class="nx">rspackVersion</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;&gt;=1.1.3&quot;</span><span class="p">),</span><span class="w"> </span><span class="s2">&quot;rspack outdated&quot;</span><span class="p">)</span> <span class="kd">const</span><span class="w"> </span><span class="nx">truthy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(...</span><span class="nx">list</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="nx">list</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">el</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="o">!!</span><span class="nx">el</span><span class="p">)</span> <span class="nx">module</span><span class="p">.</span><span class="nx">exports</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nx">PRODUCTION</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">cwd</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">process</span><span class="p">.</span><span class="nx">cwd</span><span class="p">()</span> <span class="w"> </span><span class="kd">function</span><span class="w"> </span><span class="nx">swcWithPreactRule</span><span class="p">()</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">test</span><span class="o">:</span><span class="w"> </span><span class="sr">/\.(j|t)sx?$/</span><span class="p">,</span> <span class="w"> </span><span class="nx">loader</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;builtin:swc-loader&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">exclude</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="sr">/node_modules/</span><span class="p">],</span> <span class="w"> </span><span class="nx">options</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">jsc</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">parser</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">syntax</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;ecmascript&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">jsx</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">transform</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">react</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">runtime</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;automatic&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">importSource</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;preact&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">externalHelpers</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">type</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;javascript/auto&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="kd">function</span><span class="w"> </span><span class="nx">swcWithReactRule</span><span class="p">()</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">test</span><span class="o">:</span><span class="w"> </span><span class="sr">/\.(j|t)sx?$/</span><span class="p">,</span> <span class="w"> </span><span class="nx">loader</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;builtin:swc-loader&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">exclude</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="sr">/node_modules/</span><span class="p">],</span> <span class="w"> </span><span class="nx">options</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">jsc</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">parser</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">syntax</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;ecmascript&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">jsx</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">transform</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">react</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">runtime</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;automatic&quot;</span><span class="p">,</span> <span class="w"> </span><span class="c1">// importSource: &quot;preact&quot;,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">externalHelpers</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">type</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;javascript/auto&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="kd">function</span><span class="w"> </span><span class="nx">htmlPlugin</span><span class="p">(</span><span class="nx">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">&quot;&quot;</span><span class="p">,</span><span class="w"> </span><span class="nx">config</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{})</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">HtmlWebpackPlugin</span><span class="p">({</span> <span class="w"> </span><span class="nx">filename</span><span class="o">:</span><span class="w"> </span><span class="nx">name</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="sb">`</span><span class="si">${</span><span class="nx">name</span><span class="si">}</span><span class="sb">.html`</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;[name].html&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">inject</span><span class="o">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span> <span class="w"> </span><span class="nx">templateContent</span><span class="o">:</span><span class="w"> </span><span class="p">({</span><span class="w"> </span><span class="nx">htmlWebpackPlugin</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">=&gt;</span> <span class="w"> </span><span class="sb">`</span><span class="si">${</span><span class="nx">htmlWebpackPlugin</span><span class="p">.</span><span class="nx">tags</span><span class="p">.</span><span class="nx">headTags</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="w"> </span><span class="p">...</span><span class="nx">config</span><span class="p">,</span> <span class="w"> </span><span class="p">})</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="kd">function</span><span class="w"> </span><span class="nx">htmlSingleChunkPlugin</span><span class="p">(</span><span class="nx">chunk</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">&quot;&quot;</span><span class="p">)</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nx">htmlPlugin</span><span class="p">(</span><span class="nx">chunk</span><span class="p">,</span><span class="w"> </span><span class="nx">chunk</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">chunks</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="nx">chunk</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="p">{})</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="kd">function</span><span class="w"> </span><span class="nx">postcssLoaders</span><span class="p">(</span><span class="nx">plugins</span><span class="p">)</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">loader</span><span class="o">:</span><span class="w"> </span><span class="nx">rspack</span><span class="p">.</span><span class="nx">CssExtractRspackPlugin</span><span class="p">.</span><span class="nx">loader</span><span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">loader</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;css-loader&quot;</span><span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">loader</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;postcss-loader&quot;</span><span class="p">,</span><span class="w"> </span><span class="nx">options</span><span class="o">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">postcssOptions</span><span class="o">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">plugins</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">]</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="kd">function</span><span class="w"> </span><span class="nx">cssExtractPlugin</span><span class="p">()</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">rspack</span><span class="p">.</span><span class="nx">CssExtractRspackPlugin</span><span class="p">({</span> <span class="w"> </span><span class="nx">filename</span><span class="o">:</span><span class="w"> </span><span class="nx">PRODUCTION</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="s2">&quot;[name].[contenthash].css&quot;</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;[name].css&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">chunkFilename</span><span class="o">:</span><span class="w"> </span><span class="nx">PRODUCTION</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="s2">&quot;[name].[contenthash].css&quot;</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;[name].css&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">})</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">truthy</span><span class="p">,</span> <span class="w"> </span><span class="nx">base</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">context</span><span class="o">:</span><span class="w"> </span><span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">cwd</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;frontend&quot;</span><span class="p">),</span> <span class="w"> </span><span class="nx">entry</span><span class="o">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">main</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;./main.js&quot;</span><span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">output</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">clean</span><span class="o">:</span><span class="w"> </span><span class="nx">PRODUCTION</span><span class="p">,</span> <span class="w"> </span><span class="nx">path</span><span class="o">:</span><span class="w"> </span><span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">cwd</span><span class="p">,</span><span class="w"> </span><span class="nx">PRODUCTION</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="s2">&quot;static&quot;</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;tmp&quot;</span><span class="p">),</span> <span class="w"> </span><span class="nx">publicPath</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;/static/&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">filename</span><span class="o">:</span><span class="w"> </span><span class="nx">PRODUCTION</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="s2">&quot;[name].[contenthash].js&quot;</span><span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;[name].js&quot;</span><span class="p">,</span> <span class="w"> </span><span class="c1">// Same as the default but prefixed with &quot;_/[name].&quot;</span> <span class="w"> </span><span class="nx">assetModuleFilename</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;_/[name].[hash][ext][query][fragment]&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">plugins</span><span class="o">:</span><span class="w"> </span><span class="nx">truthy</span><span class="p">(</span><span class="nx">cssExtractPlugin</span><span class="p">(),</span><span class="w"> </span><span class="nx">htmlSingleChunkPlugin</span><span class="p">()),</span> <span class="w"> </span><span class="nx">target</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;browserslist:defaults&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">devServer</span><span class="p">(</span><span class="nx">proxySettings</span><span class="p">)</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">host</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;0.0.0.0&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">hot</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="nx">port</span><span class="o">:</span><span class="w"> </span><span class="nb">Number</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">PORT</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="mf">4000</span><span class="p">),</span> <span class="w"> </span><span class="nx">allowedHosts</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;all&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">client</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">overlay</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">errors</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="nx">warnings</span><span class="o">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span> <span class="w"> </span><span class="nx">runtimeErrors</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">devMiddleware</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">headers</span><span class="o">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s2">&quot;Access-Control-Allow-Origin&quot;</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;*&quot;</span><span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">index</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="nx">writeToDisk</span><span class="o">:</span><span class="w"> </span><span class="p">(</span><span class="nx">path</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="o">/</span><span class="err">\</span><span class="p">.</span><span class="nx">html$</span><span class="o">/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">path</span><span class="p">),</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">proxy</span><span class="o">:</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="nx">proxySettings</span> <span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">context</span><span class="o">:</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span> <span class="w"> </span><span class="nx">target</span><span class="o">:</span><span class="w"> </span><span class="sb">`http://127.0.0.1:</span><span class="si">${</span><span class="nx">proxySettings</span><span class="p">.</span><span class="nx">backendPort</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="o">:</span><span class="w"> </span><span class="p">{},</span> <span class="w"> </span><span class="p">],</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">assetRule</span><span class="p">()</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">test</span><span class="o">:</span><span class="w"> </span><span class="sr">/\.(png|webp|woff2?|svg|eot|ttf|otf|gif|jpe?g|mp3|wav)$/i</span><span class="p">,</span> <span class="w"> </span><span class="nx">type</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;asset&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">parser</span><span class="o">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">dataUrlCondition</span><span class="o">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">maxSize</span><span class="o">:</span><span class="w"> </span><span class="mf">512</span><span class="w"> </span><span class="cm">/* bytes */</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">postcssRule</span><span class="p">(</span><span class="nx">cfg</span><span class="p">)</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">test</span><span class="o">:</span><span class="w"> </span><span class="sr">/\.css$/i</span><span class="p">,</span> <span class="w"> </span><span class="nx">type</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;javascript/auto&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">use</span><span class="o">:</span><span class="w"> </span><span class="nx">postcssLoaders</span><span class="p">(</span><span class="nx">cfg</span><span class="o">?</span><span class="p">.</span><span class="nx">plugins</span><span class="p">),</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">sassRule</span><span class="p">(</span><span class="nx">options</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{})</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">cssLoaders</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">options</span> <span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="nx">cssLoaders</span><span class="p">)</span><span class="w"> </span><span class="nx">cssLoaders</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">postcssLoaders</span><span class="p">([</span><span class="s2">&quot;autoprefixer&quot;</span><span class="p">])</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">test</span><span class="o">:</span><span class="w"> </span><span class="sr">/\.scss$/i</span><span class="p">,</span> <span class="w"> </span><span class="nx">use</span><span class="o">:</span><span class="w"> </span><span class="p">[</span> <span class="w"> </span><span class="p">...</span><span class="nx">cssLoaders</span><span class="p">,</span> <span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">loader</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;sass-loader&quot;</span><span class="p">,</span> <span class="w"> </span><span class="nx">options</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">sassOptions</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">includePaths</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="nx">path</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">cwd</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;node_modules&quot;</span><span class="p">))],</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">],</span> <span class="w"> </span><span class="nx">type</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;javascript/auto&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">swcWithPreactRule</span><span class="p">,</span> <span class="w"> </span><span class="nx">swcWithReactRule</span><span class="p">,</span> <span class="w"> </span><span class="nx">resolvePreactAsReact</span><span class="p">()</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">resolve</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">alias</span><span class="o">:</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">react</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;preact/compat&quot;</span><span class="p">,</span> <span class="w"> </span><span class="s2">&quot;react-dom/test-utils&quot;</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;preact/test-utils&quot;</span><span class="p">,</span> <span class="w"> </span><span class="s2">&quot;react-dom&quot;</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;preact/compat&quot;</span><span class="p">,</span><span class="w"> </span><span class="c1">// Must be below test-utils</span> <span class="w"> </span><span class="s2">&quot;react/jsx-runtime&quot;</span><span class="o">:</span><span class="w"> </span><span class="s2">&quot;preact/jsx-runtime&quot;</span><span class="p">,</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span><span class="p">},</span> <span class="w"> </span><span class="nx">htmlPlugin</span><span class="p">,</span> <span class="w"> </span><span class="nx">htmlSingleChunkPlugin</span><span class="p">,</span> <span class="w"> </span><span class="nx">postcssLoaders</span><span class="p">,</span> <span class="w"> </span><span class="nx">cssExtractPlugin</span><span class="p">,</span> <span class="w"> </span><span class="p">}</span> <span class="p">}</span> </code></pre></div> <h2 id="closing-thoughts"><a class="toclink" href="#closing-thoughts">Closing thoughts</a></h2> <p>Several utilities from this library aren&rsquo;t used in the example above, for example the <code>sassRule</code> or the HTML plugin utilities which are useful when you require several entry points on your website, e.g. an entry point for the public facing website and an entry point for a dashboard used by members of the staff.</p> <p>Most of the code in here is freely available in our <a href="https://github.com/feinheit/fh-fablib">fh-fablib</a> repo under an open source license. Anything in this blog post can also be used under the <a href="https://creativecommons.org/public-domain/cc0/">CC0</a> license, so feel free to steal everything. If you do, I&rsquo;d be happy to hear your thoughts about this post, and please share your experiences and suggestions for improvement &ndash; if you have any!</p>