Compare commits
2
Commits
7fb0afcdc5
..
v0.3.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff2f94e549 | ||
|
|
be954bc43e |
Generated
+20
-1
@@ -2948,7 +2948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.3.7"
|
||||
version = "0.3.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -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"
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.3.7"
|
||||
version = "0.3.8"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -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 }
|
||||
|
||||
+5
-14
@@ -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! {
|
||||
<section class="alt-card">
|
||||
<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 || {
|
||||
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(
|
||||
<section class="alt-card">
|
||||
<AltImages images=alternative.images.clone() />
|
||||
<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">
|
||||
<For
|
||||
each={
|
||||
@@ -945,22 +945,13 @@ fn AlternativeCard(
|
||||
let name = feature.name.clone();
|
||||
move || !name.is_empty()
|
||||
}>
|
||||
<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>
|
||||
<h3>{feature.name.clone()}</h3>
|
||||
</Show>
|
||||
<Show when={
|
||||
let description = feature.description.clone();
|
||||
move || !description.is_empty()
|
||||
}>
|
||||
<p>{feature.description.clone()}</p>
|
||||
<p inner_html=render_inline_markdown(&feature.description)></p>
|
||||
</Show>
|
||||
{resource.map(|spec| {
|
||||
view! {
|
||||
|
||||
+53
-15
@@ -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<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)]
|
||||
pub requirements: Vec<Requirement>,
|
||||
/// 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 <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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user