4 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug ff2f94e549 Release 0.3.8
Publish release / publish (push) Successful in 1m35s
Test / test (push) Successful in 25s
2026-08-30 14:44:43 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 be954bc43e 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>
2026-08-30 14:44:42 +02:00
Bendik Aagaard Lynghaug 7fb0afcdc5 Release 0.3.7
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m30s
2026-08-30 14:39:59 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 73efa6a4eb Feature.link: a card's name may point elsewhere
An https URL on a feature renders its name as an outbound link -
an announcement's programme page, a venue. Validated on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:39:58 +02:00
5 changed files with 99 additions and 6 deletions
Generated
+20 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.3.6" version = "0.3.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -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"
+4 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.3.6" version = "0.3.8"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -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 }
+4 -4
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={
@@ -951,7 +951,7 @@ fn AlternativeCard(
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
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
@@ -1897,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>");
}
} }
+18
View File
@@ -448,6 +448,24 @@ main.not-found {
margin-bottom: 0.15em; margin-bottom: 0.15em;
} }
/* links inside markdown descriptions */
.alt-description a,
.feature p a {
color: inherit;
text-decoration: none;
border-bottom: 0.06rem solid var(--accent);
}
.alt-description a:hover,
.feature p a:hover {
color: var(--accent);
}
.alt-description code,
.feature p code {
font-size: 0.9em;
}
.feature p { .feature p {
color: var(--ink-dim); color: var(--ink-dim);
font-size: 0.95rem; font-size: 0.95rem;