From 277995b9db2c92c6249b376eedb77a119b9572d4 Mon Sep 17 00:00:00 2001 From: m1rm Date: Thu, 30 Apr 2026 18:21:53 +0200 Subject: [PATCH 1/4] feat: add fixtures for frontpage news --- README.md | 1 + news/fixtures/news.json | 108 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 news/fixtures/news.json diff --git a/README.md b/README.md index 4263beeb..48a71ebd 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ probably want the following: uv run ./manage.py loaddata devel/fixtures/*.json uv run ./manage.py loaddata mirrors/fixtures/*.json uv run ./manage.py loaddata releng/fixtures/*.json + uv run ./manage.py loaddata news/fixtures/*.json 7. Use the following commands to start a service instance uv run ./manage.py runserver diff --git a/news/fixtures/news.json b/news/fixtures/news.json new file mode 100644 index 00000000..5d28e324 --- /dev/null +++ b/news/fixtures/news.json @@ -0,0 +1,108 @@ +[ + { + "model": "auth.user", + "pk": 32001, + "fields": { + "password": "!", + "last_login": null, + "is_superuser": false, + "username": "fixture_news_author", + "first_name": "Fixture", + "last_name": "NewsAuthor", + "email": "fixture-news-author@example.invalid", + "is_staff": false, + "is_active": true, + "date_joined": "2024-06-01T12:00:00Z" + } + }, + { + "model": "news.news", + "pk": 1, + "fields": { + "slug": "local-development-refresh", + "author": 32001, + "postdate": "2026-04-28T14:30:00Z", + "last_modified": "2026-04-28T14:30:00Z", + "title": "Local development workflow and fixture data", + "guid": "tag:archlinux.org,2026-04-28:/news/local-development-refresh/", + "content": "## Local development refresh\n\nWe refreshed the recommended workflow for contributors running **archweb** on their\nown machines. The steps below are split across several short paragraphs so you\ncan see how line breaks render on the home page teaser and on the full article.\n\nFirst, sync dependencies and apply migrations in a clean tree.\n\nSecond, load the bundled fixtures so package search, mirrors, and related pages\nhave something to render without pulling live data.\n\nTypical commands:\n\n uv sync\n uv run ./manage.py migrate\n uv run ./manage.py loaddata main/fixtures/*.json\n\nIf you only need a subset, adjust the glob. When something goes wrong, check the\ntraceback and the Django logs before opening an issue.\n\nShell helpers (same idea, different shell features):\n\n #!/usr/bin/env bash\n set -euo pipefail\n export DJANGO_SETTINGS_MODULE=settings\n uv run ./manage.py check\n\nThat is all for this announcement.", + "safe_mode": true, + "send_announce": false + } + }, + { + "model": "news.news", + "pk": 2, + "fields": { + "slug": "linux-rebuild-core-april", + "author": 32001, + "postdate": "2026-04-20T09:15:00Z", + "last_modified": "2026-04-20T09:15:00Z", + "title": "[core] linux rebuild (no ABI change)", + "guid": "tag:archlinux.org,2026-04-20:/news/linux-rebuild-core-april/", + "content": "### linux package rebuild\n\nA routine rebuild landed in [core] with no ABI changes expected.\n\nHighlights from the maintainer notes:\n\n # pacman -Syu\n # reboot if you replaced the running kernel\n\nNothing else is required unless you use out-of-tree modules.", + "safe_mode": true, + "send_announce": false + } + }, + { + "model": "news.news", + "pk": 3, + "fields": { + "slug": "wiki-mirror-rotation", + "author": 32001, + "postdate": "2026-04-10T16:00:00Z", + "last_modified": "2026-04-10T16:00:00Z", + "title": "Wiki mirror rotation completed", + "guid": "tag:archlinux.org,2026-04-10:/news/wiki-mirror-rotation/", + "content": "Wiki mirrors were rotated. No user action is needed.\n\nExample session you might run locally (one indented code block, multiple lines):\n\n # Step 1: response headers\n curl -I https://wiki.archlinux.org/\n # Step 2: status code only (body discarded)\n curl -sL -o /dev/null -w 'status=%{http_code}\\n' https://wiki.archlinux.org/\n # Step 3: quick timing summary\n curl -sL -o /dev/null -w 'namelookup=%{time_namelookup}s connect=%{time_connect}s total=%{time_total}s\\n' \\\n https://wiki.archlinux.org/\n echo 'Connectivity check complete'\n\nThe response should be **HTTP/2** or **HTTP/1.1** with a normal status code.", + "safe_mode": true, + "send_announce": false + } + }, + { + "model": "news.news", + "pk": 4, + "fields": { + "slug": "schedule-illustrative", + "author": 32001, + "postdate": "2026-03-22T11:45:00Z", + "last_modified": "2026-03-22T11:45:00Z", + "title": "Illustrative maintenance schedule", + "guid": "tag:archlinux.org,2026-03-22:/news/schedule-illustrative/", + "content": "Upcoming schedule (all dates are illustrative for this fixture):\n\n- Monday: sync databases\n- Tuesday: run the full test suite\n\nInline check:\n\n pytest -q\n\nEnd of list demo.", + "safe_mode": true, + "send_announce": false + } + }, + { + "model": "news.news", + "pk": 5, + "fields": { + "slug": "python-snippet-formatting", + "author": 32001, + "postdate": "2026-02-05T08:00:00Z", + "last_modified": "2026-02-05T08:00:00Z", + "title": "Python snippet for preview formatting", + "guid": "tag:archlinux.org,2026-02-05:/news/python-snippet-formatting/", + "content": "Small **Python** snippet for formatting checks:\n\n def greet(name: str) -> str:\n message = f\"Hello, {name}\"\n return message\n\n if __name__ == \"__main__\":\n print(greet(\"Arch\"))", + "safe_mode": true, + "send_announce": false + } + }, + { + "model": "news.news", + "pk": 6, + "fields": { + "slug": "plain-paragraph-linebreaks", + "author": 32001, + "postdate": "2026-01-15T10:00:00Z", + "last_modified": "2026-01-15T10:00:00Z", + "title": "Plain text line breaks in one paragraph", + "guid": "tag:archlinux.org,2026-01-15:/news/plain-paragraph-linebreaks/", + "content": "Plain paragraph fixture with soft line breaks in the source\nfile: this sentence continues after a single newline inside the paragraph in\nMarkdown, which usually joins lines into one HTML paragraph when you view the\nfull article.", + "safe_mode": true, + "send_announce": false + } + } +] From 67a0960f95b02a605a8b3b4007a27af5640e44b1 Mon Sep 17 00:00:00 2001 From: m1rm Date: Thu, 30 Apr 2026 19:12:06 +0200 Subject: [PATCH 2/4] bugfix(#573): use Django's builtin linebreak filter to preserve newlines --- templates/public/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/public/index.html b/templates/public/index.html index cb376f92..59c40ca2 100644 --- a/templates/public/index.html +++ b/templates/public/index.html @@ -52,8 +52,8 @@

{{ news.postdate|date:"Y-m-d" }}

- {% if forloop.counter0 == 0 %}{{ news.html|truncatewords_html:300 }} - {% else %}{{ news.html|truncatewords_html:100 }}{% endif %} + {% if forloop.counter0 == 0 %}{{ news.html|linebreaks|truncatewords_html:300 }} + {% else %}{{ news.html|linebreaks|truncatewords_html:100 }}{% endif %}
{% else %}{% if forloop.counter0 == 5 %}

From be394bf49cfaa7e338cf728a74657e2854c53f37 Mon Sep 17 00:00:00 2001 From: m1rm Date: Thu, 30 Apr 2026 19:32:48 +0200 Subject: [PATCH 3/4] bugfix(#573): add a test for newline preservation bugfix(#573): add a test for newline preservation --- public/tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/public/tests.py b/public/tests.py index 47aa6c8c..804b01cb 100644 --- a/public/tests.py +++ b/public/tests.py @@ -1,3 +1,19 @@ +from django.template import Context, Template + + +def test_news_preview_filter_chain_preserves_multiline_pre(): + html = ( + "

Intro

" + "
line one\nline two\nline three
" + "

Outro

" + ) + template = Template("{{ html|linebreaks|truncatewords_html:100 }}") + rendered = template.render(Context({"html": html})) + + assert "line one
line two
line three" in rendered + assert "line one line two line three" not in rendered + + def test_index(client, arches, repos, package, groups, staff_groups): response = client.get('/') assert response.status_code == 200 From 44c565267eda7c8a932d1e9b48a1df4791065cd9 Mon Sep 17 00:00:00 2001 From: m1rm Date: Thu, 30 Apr 2026 19:45:19 +0200 Subject: [PATCH 4/4] bugfix(#573): use a custom filter to preserve newlines only in codeblocks --- main/templatetags/htmltruncate.py | 55 +++++++++++++++++++++++++++++++ public/tests.py | 26 ++++++++------- settings.py | 1 + sitestatic/archweb.css | 4 ++- templates/public/index.html | 4 +-- 5 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 main/templatetags/htmltruncate.py diff --git a/main/templatetags/htmltruncate.py b/main/templatetags/htmltruncate.py new file mode 100644 index 00000000..9e96bf28 --- /dev/null +++ b/main/templatetags/htmltruncate.py @@ -0,0 +1,55 @@ +import re + +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.html import escape +from django.utils.text import TruncateWordsHTMLParser + +register = template.Library() + + +class PrePreservingTruncateWordsHTMLParser(TruncateWordsHTMLParser): + """ + Like Django's TruncateWordsHTMLParser, but text inside
 keeps its
+    original whitespace (truncatewords_html collapses newlines to spaces).
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._pre_depth = 0
+
+    def handle_starttag(self, tag, attrs):
+        if tag.lower() == 'pre':
+            self._pre_depth += 1
+        super().handle_starttag(tag, attrs)
+
+    def handle_endtag(self, tag):
+        super().handle_endtag(tag)
+        if tag.lower() == 'pre':
+            self._pre_depth = max(0, self._pre_depth - 1)
+
+    def process(self, data):
+        if self._pre_depth <= 0:
+            return super().process(data)
+        parts = re.split(r'(?<=\S)\s+(?=\S)', data)
+        if not data:
+            return [], ''
+        if len(parts) <= self.remaining:
+            return parts, escape(data)
+        output = escape(' '.join(parts[: self.remaining]))
+        return parts, output
+
+
+@register.filter(is_safe=True)
+@stringfilter
+def truncatewords_html_preserve_pre(value, arg):
+    try:
+        length = int(arg)
+    except ValueError:
+        return value
+    if length <= 0:
+        return ''
+    parser = PrePreservingTruncateWordsHTMLParser(length=length, replacement=' …')
+    parser.feed(value)
+    parser.close()
+    return parser.output
diff --git a/public/tests.py b/public/tests.py
index 804b01cb..895f9bd8 100644
--- a/public/tests.py
+++ b/public/tests.py
@@ -1,17 +1,21 @@
-from django.template import Context, Template
+from django.template import engines
+from django.utils.safestring import mark_safe
 
+from main.templatetags.htmltruncate import truncatewords_html_preserve_pre
 
-def test_news_preview_filter_chain_preserves_multiline_pre():
-    html = (
-        "

Intro

" - "
line one\nline two\nline three
" - "

Outro

" - ) - template = Template("{{ html|linebreaks|truncatewords_html:100 }}") - rendered = template.render(Context({"html": html})) - assert "line one
line two
line three" in rendered - assert "line one line two line three" not in rendered +def test_truncatewords_html_preserve_pre_keeps_pre_newlines(): + html = mark_safe( + '

Intro

line one\nline two\nline three
' + ) + out = truncatewords_html_preserve_pre(html, 50) + assert 'line one\nline two' in out + assert 'line one line two line three' not in out + + engine = engines['django'] + t = engine.from_string('{{ x|truncatewords_html_preserve_pre:50 }}') + rendered = t.render({'x': html}) + assert 'line one\nline two' in rendered def test_index(client, arches, repos, package, groups, staff_groups): diff --git a/settings.py b/settings.py index 22fb8a8f..6820f0da 100644 --- a/settings.py +++ b/settings.py @@ -254,6 +254,7 @@ 'csp.context_processors.nonce', 'main.context_processors.mastodon_link', ], + 'builtins': ['main.templatetags.htmltruncate'], "loaders": [ ( "django.template.loaders.cached.Loader", diff --git a/sitestatic/archweb.css b/sitestatic/archweb.css index 1d8d50f3..9dc9bc23 100644 --- a/sitestatic/archweb.css +++ b/sitestatic/archweb.css @@ -48,12 +48,14 @@ pre { background: #dfd; padding: 0.5em; margin: 1em; + white-space: pre-wrap; + overflow-wrap: break-word; } pre code { display: block; background: none; - overflow: auto; + overflow: visible; } blockquote { diff --git a/templates/public/index.html b/templates/public/index.html index 59c40ca2..30781150 100644 --- a/templates/public/index.html +++ b/templates/public/index.html @@ -52,8 +52,8 @@

{{ news.postdate|date:"Y-m-d" }}

- {% if forloop.counter0 == 0 %}{{ news.html|linebreaks|truncatewords_html:300 }} - {% else %}{{ news.html|linebreaks|truncatewords_html:100 }}{% endif %} + {% if forloop.counter0 == 0 %}{{ news.html|truncatewords_html_preserve_pre:300 }} + {% else %}{{ news.html|truncatewords_html_preserve_pre:100 }}{% endif %}
{% else %}{% if forloop.counter0 == 5 %}