From e697de948db3e4d670883ddb0754bb061b0cccbb 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 69de8329..2f59f850 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,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 f5435909b055a00a252a5d97b47d265d3756bea8 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 5757a137a6ada76eb13b42cb4ba510e466e48e9e 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 d05be818d9d4efe53fc0a9a52664d6b6627a5d59 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 b3b6c5d3..a5a9bd68 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 %}