diff --git a/Cargo.lock b/Cargo.lock index fe1c27b..3e80ab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2970,6 +2970,7 @@ dependencies = [ "leptos_router", "openidconnect", "proptest", + "pulldown-cmark", "serde", "serde_json", "serde_yaml", @@ -3104,6 +3105,24 @@ dependencies = [ "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]] name = "quick-error" version = "1.2.3" diff --git a/Cargo.toml b/Cargo.toml index 6444a47..9303469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ leptos_meta = { version = "0.8" } leptos_router = { version = "0.8" } serde = { version = "1", features = ["derive"] } 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 --- leptos_axum = { version = "0.8", optional = true } diff --git a/src/app.rs b/src/app.rs index 93c3f7b..793a4b9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; 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}; /// The visible site name/wordmark - "portal" is just this codebase's @@ -680,7 +680,7 @@ fn AlternativeCard( return view! {

{alternative.name.clone()}

-

{alternative.description.clone()}

+

{move || { let question_id_for_action = question_id_for_action.clone(); let alt_name_for_action = alt_name_for_action.clone(); @@ -902,7 +902,7 @@ fn AlternativeCard(

{alternative.name.clone()}

-

{alternative.description.clone()}

+

-

- {match feature.link.clone() { - Some(href) => view! { - - {feature.name.clone()} - - }.into_any(), - None => feature.name.clone().into_any(), - }} -

+

{feature.name.clone()}

-

{feature.description.clone()}

+

{resource.map(|spec| { view! { diff --git a/src/content.rs b/src/content.rs index f58f94b..8855be4 100644 --- a/src/content.rs +++ b/src/content.rs @@ -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 /// question at or below its directory (nearest ancestor wins per /// 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. #[serde(default)] pub icon: Option, - /// 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, #[serde(default)] pub requirements: Vec, /// Live data this feature pulls in. Read-only unless `transitions` @@ -1022,16 +1050,6 @@ pub fn validate_questions( } } 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 // button navigates to "Nothing here". if let Some(action) = &alternative.action { @@ -1912,4 +1930,24 @@ alternatives: assert!(bucket_is_read(&qs, "things")); 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 the programme soon." + ); + 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 z"), "x y z"); + assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad"); + assert_eq!(render_inline_markdown("[ok](/shape)"), "ok"); + assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "mail"); + } } diff --git a/style/main.css b/style/main.css index 3f79551..5552094 100644 --- a/style/main.css +++ b/style/main.css @@ -448,15 +448,22 @@ main.not-found { margin-bottom: 0.15em; } -.feature h3 a { +/* links inside markdown descriptions */ +.alt-description a, +.feature p a { color: inherit; 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); - border-bottom-color: var(--accent); +} + +.alt-description code, +.feature p code { + font-size: 0.9em; } .feature p {