Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff2f94e549 | ||
|
|
be954bc43e | ||
|
|
7fb0afcdc5 | ||
|
|
73efa6a4eb |
Generated
+20
-1
@@ -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
@@ -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
@@ -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! {
|
||||||
|
|||||||
@@ -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>");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user