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/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/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
+ }
+ }
+]
diff --git a/public/tests.py b/public/tests.py
index 47aa6c8c..895f9bd8 100644
--- a/public/tests.py
+++ b/public/tests.py
@@ -1,3 +1,23 @@
+from django.template import engines
+from django.utils.safestring import mark_safe
+
+from main.templatetags.htmltruncate import truncatewords_html_preserve_pre
+
+
+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):
response = client.get('/')
assert response.status_code == 200
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 cb376f92..30781150 100644
--- a/templates/public/index.html
+++ b/templates/public/index.html
@@ -52,8 +52,8 @@
- {% if forloop.counter0 == 0 %}{{ news.html|truncatewords_html:300 }}
- {% else %}{{ news.html|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 %}