A bio is markdown, on stricter rules than the site's own
Test / test (pull_request) Successful in 1m9s
Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c6dffc384c
commit
eb982ec6e6
@@ -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<version>` tag; the
|
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||||
content repos pin the tag they run (see the end of this file).
|
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)
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
`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
|
`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
|
that is public by law — public about what was decided, not about what
|
||||||
|
|||||||
@@ -507,7 +507,7 @@
|
|||||||
"hints": {
|
"hints": {
|
||||||
"name": "",
|
"name": "",
|
||||||
"rolle": "For eksempel daglig leder, rådgivende ingeniør",
|
"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."
|
"portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
|
||||||
},
|
},
|
||||||
"question": "Ditt kort på forsiden",
|
"question": "Ditt kort på forsiden",
|
||||||
|
|||||||
+6
-1
@@ -2553,7 +2553,12 @@ fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl In
|
|||||||
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
|
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
|
||||||
<div class="answer-card-text">
|
<div class="answer-card-text">
|
||||||
<h4 class="answer-card-heading">{heading}</h4>
|
<h4 class="answer-card-heading">{heading}</h4>
|
||||||
{rest.into_iter().map(|t| view! { <p>{t}</p> }).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! { <div class="card-field" inner_html=crate::content::render_written_markdown(&t)></div> })
|
||||||
|
.collect_view()}
|
||||||
</div>
|
</div>
|
||||||
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
|
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -118,3 +118,67 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
|
|||||||
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Markdown that a person wrote into a record - a bio on a card - as
|
||||||
|
/// opposed to the site's own content. Paragraphs, line breaks as they
|
||||||
|
/// were typed, lists, emphasis and links; no images (a picture has its
|
||||||
|
/// own field, and an image in text is a way to load something from
|
||||||
|
/// elsewhere into every visitor's page), no raw HTML, headings as plain
|
||||||
|
/// paragraphs. Links go to https, mailto or tel only, and say they are
|
||||||
|
/// the writer's, not the site's (`rel="nofollow ugc"`).
|
||||||
|
pub fn render_written_markdown(text: &str) -> 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("<a href=", "<a rel=\"nofollow ugc\" href=")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod written_tests {
|
||||||
|
use super::render_written_markdown as md;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
|
||||||
|
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
|
||||||
|
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
|
||||||
|
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
|
||||||
|
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
|
||||||
|
// Everything that would put something else in the page is gone.
|
||||||
|
assert_eq!(md("<script>alert(1)</script>"), "");
|
||||||
|
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
|
||||||
|
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
|
||||||
|
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
|
||||||
|
assert_eq!(md("before  after"), "<p>before after</p>");
|
||||||
|
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+26
-4
@@ -1300,14 +1300,36 @@ textarea:focus {
|
|||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-card-text p {
|
.card-field {
|
||||||
margin: 0;
|
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: var(--ink-dim);
|
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);
|
color: var(--ink);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user