Descriptions are inline markdown; Feature.link withdrawn

Alternative and feature descriptions render links, emphasis and code
(pulldown-cmark, html feature only). Block structure flattens to one
paragraph, raw HTML is dropped, link targets are limited to https,
mailto and site-relative paths. A link belongs in the prose, so the
title-link field shipped in 0.3.7 goes before anyone uses it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-30 14:44:42 +02:00
co-authored by Claude Fable 5
parent 7fb0afcdc5
commit be954bc43e
5 changed files with 91 additions and 33 deletions
Generated
+19
View File
@@ -2970,6 +2970,7 @@ dependencies = [
"leptos_router", "leptos_router",
"openidconnect", "openidconnect",
"proptest", "proptest",
"pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -3104,6 +3105,24 @@ dependencies = [
"unarray", "unarray",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.1",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
+3
View File
@@ -12,6 +12,9 @@ leptos_meta = { version = "0.8" }
leptos_router = { version = "0.8" } leptos_router = { version = "0.8" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Inline markdown for content descriptions (links, emphasis, code) -
# runs on both server and client renders, so not feature-gated.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
# --- server only --- # --- server only ---
leptos_axum = { version = "0.8", optional = true } leptos_axum = { version = "0.8", optional = true }
+5 -14
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User}; use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Responsible, SiteConfig, Transition}; use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource}; use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's /// The visible site name/wordmark - "portal" is just this codebase's
@@ -680,7 +680,7 @@ fn AlternativeCard(
return view! { return view! {
<section class="alt-card"> <section class="alt-card">
<h2>{alternative.name.clone()}</h2> <h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p> <p class="alt-description" inner_html=render_inline_markdown(&alternative.description)></p>
{move || { {move || {
let question_id_for_action = question_id_for_action.clone(); let question_id_for_action = question_id_for_action.clone();
let alt_name_for_action = alt_name_for_action.clone(); let alt_name_for_action = alt_name_for_action.clone();
@@ -902,7 +902,7 @@ fn AlternativeCard(
<section class="alt-card"> <section class="alt-card">
<AltImages images=alternative.images.clone() /> <AltImages images=alternative.images.clone() />
<h2>{alternative.name.clone()}</h2> <h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p> <p class="alt-description" inner_html=render_inline_markdown(&alternative.description)></p>
<div class="features"> <div class="features">
<For <For
each={ each={
@@ -945,22 +945,13 @@ fn AlternativeCard(
let name = feature.name.clone(); let name = feature.name.clone();
move || !name.is_empty() move || !name.is_empty()
}> }>
<h3> <h3>{feature.name.clone()}</h3>
{match feature.link.clone() {
Some(href) => view! {
<a href=href target="_blank" rel="noopener noreferrer">
{feature.name.clone()}
</a>
}.into_any(),
None => feature.name.clone().into_any(),
}}
</h3>
</Show> </Show>
<Show when={ <Show when={
let description = feature.description.clone(); let description = feature.description.clone();
move || !description.is_empty() move || !description.is_empty()
}> }>
<p>{feature.description.clone()}</p> <p inner_html=render_inline_markdown(&feature.description)></p>
</Show> </Show>
{resource.map(|spec| { {resource.map(|spec| {
view! { view! {
+53 -15
View File
@@ -136,6 +136,39 @@ impl Question {
} }
} }
/// Descriptions are inline markdown: `[text](https://…)` links,
/// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is
/// flattened (a description is one paragraph) and raw HTML in the
/// source is dropped, so content can link out without being able to
/// inject markup. Link targets are limited to https, mailto and
/// site-relative paths; anything else renders as plain text.
pub fn render_inline_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::empty());
let mut in_link = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None,
Event::SoftBreak => Some(Event::Text(" ".into())),
Event::Start(Tag::Link { dest_url, .. })
if !(dest_url.starts_with("https://")
|| dest_url.starts_with("mailto:")
|| dest_url.starts_with('/')) =>
{
in_link += 1;
None
}
Event::End(TagEnd::Link) if in_link > 0 => {
in_link -= 1;
None
}
other => Some(other),
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().to_string()
}
/// Directory-scoped defaults: a `_section.yaml` file applies to every /// Directory-scoped defaults: a `_section.yaml` file applies to every
/// question at or below its directory (nearest ancestor wins per /// question at or below its directory (nearest ancestor wins per
/// field, and a question's own declaration always overrides). This is /// field, and a question's own declaration always overrides). This is
@@ -237,11 +270,6 @@ pub struct Feature {
/// rendered via Iconify's public SVG API - no icon library bundled. /// rendered via Iconify's public SVG API - no icon library bundled.
#[serde(default)] #[serde(default)]
pub icon: Option<String>, pub icon: Option<String>,
/// An https URL the card's name links to - an announcement's
/// programme page, a venue. Plain-text descriptions can't carry
/// links, so this is the one place a card points elsewhere.
#[serde(default)]
pub link: Option<String>,
#[serde(default)] #[serde(default)]
pub requirements: Vec<Requirement>, pub requirements: Vec<Requirement>,
/// Live data this feature pulls in. Read-only unless `transitions` /// Live data this feature pulls in. Read-only unless `transitions`
@@ -1022,16 +1050,6 @@ pub fn validate_questions(
} }
} }
for alternative in &question.alternatives { for alternative in &question.alternatives {
for feature in &alternative.features {
if let Some(link) = &feature.link {
if !link.starts_with("https://") {
anyhow::bail!(
"question {:?} feature {:?}: link {:?} must be an https:// URL",
question.id, feature.name, link
);
}
}
}
// A dangling action is a literal dead end: the submit // A dangling action is a literal dead end: the submit
// button navigates to "Nothing here". // button navigates to "Nothing here".
if let Some(action) = &alternative.action { if let Some(action) = &alternative.action {
@@ -1912,4 +1930,24 @@ alternatives:
assert!(bucket_is_read(&qs, "things")); assert!(bucket_is_read(&qs, "things"));
assert!(!bucket_is_read(&qs, EVENTS_BUCKET)); assert!(!bucket_is_read(&qs, EVENTS_BUCKET));
} }
// ── inline markdown ──────────────────────────────────────────────────
#[test]
fn markdown_links_and_emphasis() {
assert_eq!(
render_inline_markdown("See [the programme](https://attac.no/x) *soon*."),
"See <a href=\"https://attac.no/x\">the programme</a> <em>soon</em>."
);
assert_eq!(render_inline_markdown("plain text"), "plain text");
assert_eq!(render_inline_markdown("a\nb"), "a b");
}
#[test]
fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad");
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
}
} }
+11 -4
View File
@@ -448,15 +448,22 @@ main.not-found {
margin-bottom: 0.15em; margin-bottom: 0.15em;
} }
.feature h3 a { /* links inside markdown descriptions */
.alt-description a,
.feature p a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
border-bottom: 0.06rem solid var(--line); border-bottom: 0.06rem solid var(--accent);
} }
.feature h3 a:hover { .alt-description a:hover,
.feature p a:hover {
color: var(--accent); color: var(--accent);
border-bottom-color: var(--accent); }
.alt-description code,
.feature p code {
font-size: 0.9em;
} }
.feature p { .feature p {