Compare commits

..
6 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 2c94ba4379 Release 0.3.30
Test / test (push) Successful in 28s
Publish release / publish (push) Successful in 1m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 065bd0a86a Single alt-image shows natural height
Was cropped to a 14rem band via object-fit: cover; now shows the whole
image at natural aspect, bounded at 32rem so a tall one can't tower.
The image deck keeps its fixed-height cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:42:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 035ad7830b Release 0.3.29
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 bf7f2e11e7 Markdown image layout hints + AVIF assets
Content-only image control: a markdown image title carries layout —
left/right to float text around it, a number for max-width in rem
(validated, capped) — translated to md-float-* classes and a numeric
inline max-width. No arbitrary CSS reaches the page. Portal also now
serves .avif site assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 21:36:57 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 57b70c8445 Release 0.3.28
Test / test (push) Successful in 27s
Publish release / publish (push) Successful in 1m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0d3221c22f Hero description renders inline markdown
The question-level description (hero subtitle) was plain text, so
**bold** showed literal asterisks. Now it runs through
render_inline_markdown like every other description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
2026-09-01 20:31:02 +02:00
5 changed files with 116 additions and 6 deletions
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.26"
version = "0.3.28"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.27"
version = "0.3.30"
edition = "2021"
[lib]
+1 -1
View File
@@ -687,7 +687,7 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig, cur
<img src=wordmark alt=site_title style=wordmark_style/>
</a>
<h1>{title}</h1>
<p>{description}</p>
<p inner_html=render_inline_markdown(&description)></p>
</div>
</header>
}
+83 -1
View File
@@ -219,7 +219,73 @@ pub fn render_inline_markdown(text: &str) -> String {
});
let mut out = String::new();
html::push_html(&mut out, filtered);
out.trim().to_string()
apply_image_hints(out.trim())
}
/// Translate a markdown image's title (`![alt](url "right 9rem")`)
/// into layout: `left`/`right` float via a class, a bare number
/// (optionally with `rem`) into a validated `max-width`. Unknown
/// tokens are ignored; the title attribute is dropped either way.
/// Only touches `<img>` tags in our own render output, and only ever
/// emits a numeric max-width - no arbitrary CSS reaches the page.
fn apply_image_hints(html: &str) -> String {
let mut out = String::new();
let mut rest = html;
while let Some(pos) = rest.find("<img ") {
out.push_str(&rest[..pos]);
let after = &rest[pos..];
let end = after.find('>').map(|e| e + 1).unwrap_or(after.len());
out.push_str(&rewrite_img_tag(&after[..end]));
rest = &after[end..];
}
out.push_str(rest);
out
}
fn rewrite_img_tag(tag: &str) -> String {
// Pull the title value, if any.
let title = tag
.find("title=\"")
.map(|i| &tag[i + 7..])
.and_then(|r| r.find('"').map(|e| &r[..e]))
.unwrap_or("");
let mut class = String::new();
let mut max_rem: Option<f32> = None;
for tok in title.split_whitespace() {
match tok {
"left" => class = "md-float-left".into(),
"right" => class = "md-float-right".into(),
other => {
if let Ok(n) = other.trim_end_matches("rem").parse::<f32>() {
if n > 0.0 && n <= 60.0 {
max_rem = Some(n);
}
}
}
}
}
// Strip the title attribute from the tag.
let mut cleaned = tag.to_string();
if let Some(i) = cleaned.find(" title=\"") {
if let Some(e) = cleaned[i + 8..].find('"') {
cleaned.replace_range(i..i + 8 + e + 1, "");
}
}
// Inject class/style right after `<img`.
let mut attrs = String::new();
if !class.is_empty() {
attrs.push_str(&format!(" class=\"{class}\""));
}
if let Some(n) = max_rem {
attrs.push_str(&format!(" style=\"max-width:{n}rem\""));
}
if attrs.is_empty() {
return cleaned;
}
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
}
/// Directory-scoped defaults: a `_section.yaml` file applies to every
@@ -1469,6 +1535,7 @@ pub async fn site_asset_handler(
Some("json") => "application/json",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
};
@@ -2088,6 +2155,21 @@ alternatives:
render_inline_markdown("![local](/images/a.jpg)"),
"<img src=\"/images/a.jpg\" alt=\"local\" />"
);
// Image title hints: float + max-width, title dropped.
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"right 9rem\")"),
"<img class=\"md-float-right\" style=\"max-width:9rem\" src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"12\")"),
"<img style=\"max-width:12rem\" src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
// Unknown hint tokens are ignored; a safe image with no title
// is untouched.
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"wat\")"),
"<img src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
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>");
}
+30 -2
View File
@@ -368,8 +368,12 @@ main.not-found {
.alt-image {
display: block;
width: 100%;
max-height: 14rem;
object-fit: cover;
height: auto;
/* Natural aspect, not a cropped band; a very tall image is still
bounded so it can't tower. The deck below overrides for its
fixed-height cards. */
max-height: 32rem;
object-fit: contain;
border-radius: calc(var(--radius) - 0.3rem);
margin-bottom: 1.1rem;
}
@@ -552,6 +556,30 @@ main.not-found {
border: 0.06rem solid var(--line);
}
/* Content hint via a markdown image title (`![alt](url "right 9rem")`):
float and shrink so text wraps around a portrait. */
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left,
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
width: auto;
max-width: 45%;
}
.alt-description img.md-float-left,
.item-card-description img.md-float-left,
.feature p img.md-float-left {
float: left;
margin: 0.2rem 1.1rem 0.5rem 0;
}
.alt-description img.md-float-right,
.item-card-description img.md-float-right,
.feature p img.md-float-right {
float: right;
margin: 0.2rem 0 0.5rem 1.1rem;
}
.alt-description code,
.feature p code {
font-size: 0.9em;