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.clone()

{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 ![pixel](https://tracker.example/p.gif) 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; } -- 2.54.0