From eb982ec6e6760669ff8a77bce408ebb3753a202e Mon Sep 17 00:00:00 2001
From: Bendik Aagaard Lynghaug
Date: Wed, 30 Sep 2026 21:38:54 +0200
Subject: [PATCH] A bio is markdown, on stricter rules than the site's own
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
---
CHANGELOG.md | 4 ++
docs/content-reference.md | 5 ++-
plans/words/klingenbergbygg.json | 2 +-
src/app.rs | 7 +++-
src/content/markdown.rs | 64 ++++++++++++++++++++++++++++++++
style/main.css | 30 +++++++++++++--
6 files changed, 105 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8de2571..81171a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v` tag; the
content repos pin the tag they run (see the end of this file).
+## Unreleased
+
+- What a person wrote on a card (a bio) is shown as markdown: paragraphs, line breaks as typed, lists, emphasis, and links to https, mailto or tel marked `rel="nofollow ugc"`. No images and no raw HTML, which is stricter than the site's own descriptions. The desk still shows the text as written.
+
## 0.6.8 (2026-09-30)
- An upload that did not arrive says why: larger than 20 MB (checked in the browser before anything is sent), a site that takes no files (no Garage configured), not allowed from here, or anything else. It used to say "try again, or with a smaller file" whatever the reason, which sent people looking for a smaller file when the site had no storage at all.
diff --git a/docs/content-reference.md b/docs/content-reference.md
index 14bc87c..e232167 100644
--- a/docs/content-reference.md
+++ b/docs/content-reference.md
@@ -275,7 +275,10 @@ time. The cards appear in the order the records were put up. That is
how a site shows the people who work there, or a gallery of what it
has built. On a desk, the same picture is a thumbnail that opens.
`accept: image/jpeg,image/png,image/webp` on the field makes a phone
-send a JPEG whatever it keeps its photos as.
+send a JPEG whatever it keeps its photos as. What the person wrote is shown as
+markdown on stricter rules than the site's own: paragraphs, line
+breaks as typed, lists, emphasis and links to https, mailto or tel
+(marked `nofollow ugc`); no images, no raw HTML.
`states` and `where` narrow a listing. They matter most for a register
that is public by law — public about what was decided, not about what
diff --git a/plans/words/klingenbergbygg.json b/plans/words/klingenbergbygg.json
index 95dbcd0..14c50ff 100644
--- a/plans/words/klingenbergbygg.json
+++ b/plans/words/klingenbergbygg.json
@@ -507,7 +507,7 @@
"hints": {
"name": "",
"rolle": "For eksempel daglig leder, rådgivende ingeniør",
- "erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av",
+ "erfaring": "Utdanning, fagområder, bygg du er stolt av. Du kan skrive **fet**, *kursiv*, lister med - og [lenker](https://…).",
"portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
},
"question": "Ditt kort på forsiden",
diff --git a/src/app.rs b/src/app.rs
index 8f2510f..ed3e9af 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2553,7 +2553,12 @@ fn AnswerCard(answer: Answer, picture: String, link: Option) -> impl In
{heading}
- {rest.into_iter().map(|t| view! {
{t}
}).collect_view()}
+ // What the person wrote, as they wrote it: markdown, on
+ // the stricter rules for text that is not the site's.
+ {rest
+ .into_iter()
+ .map(|t| view! {
})
+ .collect_view()}
{link.map(|(href, label)| view! { {label} })}
diff --git a/src/content/markdown.rs b/src/content/markdown.rs
index 9738c55..07b0b4e 100644
--- a/src/content/markdown.rs
+++ b/src/content/markdown.rs
@@ -118,3 +118,67 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
cleaned.replacen("
String {
+ use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
+ let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
+ let mut in_dropped_link = 0usize;
+ let mut in_image = 0usize;
+ let filtered = parser.filter_map(|event| match event {
+ Event::Html(_) | Event::InlineHtml(_) => None,
+ Event::Start(Tag::Image { .. }) => {
+ in_image += 1;
+ None
+ }
+ Event::End(TagEnd::Image) => {
+ in_image = in_image.saturating_sub(1);
+ None
+ }
+ _ if in_image > 0 => None,
+ Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
+ Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
+ // What someone typed on a new line is on a new line.
+ Event::SoftBreak => Some(Event::HardBreak),
+ Event::Start(Tag::Link { dest_url, .. })
+ if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
+ {
+ in_dropped_link += 1;
+ None
+ }
+ Event::End(TagEnd::Link) if in_dropped_link > 0 => {
+ in_dropped_link -= 1;
+ None
+ }
+ other => Some(other),
+ });
+ let mut out = String::new();
+ html::push_html(&mut out, filtered);
+ out.trim().replace("Line one
\nline two
");
+ assert!(md("- NS 3424\n- NS 3423").contains("\n- NS 3424
"));
+ assert!(md("**Sivilingeniør**, *NTNU*").contains("Sivilingeniør, NTNU"));
+ assert_eq!(md("[Westra](https://westra.no)"), "Westra
");
+ // Everything that would put something else in the page is gone.
+ assert_eq!(md(""), "");
+ assert_eq!(md("hi
"), "hi
");
+ assert_eq!(md("[click](javascript:alert(1))"), "click
");
+ assert_eq!(md("[home](/kontor)"), "home
", "a site path is the site's to link, not a bio's");
+ assert_eq!(md("before  after"), "before after
");
+ assert_eq!(md("# Shouting"), "Shouting
");
+ }
+}
diff --git a/style/main.css b/style/main.css
index 5a14500..eda4044 100644
--- a/style/main.css
+++ b/style/main.css
@@ -1300,14 +1300,36 @@ textarea:focus {
font-size: 1.05rem;
}
-.answer-card-text p {
- margin: 0;
+.card-field {
font-size: 0.92rem;
color: var(--ink-dim);
- white-space: pre-line;
}
-.answer-card-text p:first-of-type {
+.card-field > :first-child {
+ margin-top: 0;
+}
+
+.card-field > :last-child {
+ margin-bottom: 0;
+}
+
+.card-field p,
+.card-field ul,
+.card-field ol {
+ margin: 0 0 0.5em;
+}
+
+.card-field ul,
+.card-field ol {
+ padding-left: 1.2em;
+}
+
+.card-field a {
+ color: var(--accent);
+}
+
+/* The field right under the name - a role - reads as part of the name. */
+.answer-card-text .card-field:first-of-type {
color: var(--ink);
font-weight: 600;
}