use super::Alert;
use crate::csp::{Csp, EMPTY, embed_url};
use crate::i18n::Text;
use crate::render::{Render, escape};
use crate::screen::{Component, Node};
use serde::Serialize;
use serde_json::{Value as Json, json};
use std::marker::PhantomData;
pub struct NoTitle;
pub struct NoAlt;
pub struct NoSize;
pub struct Given;
#[diagnostic::on_unimplemented(
message = "`{Self}` is missing a required part and cannot be placed on a screen",
label = "not finished",
note = "an `Embed` needs `.title(t!(...))`; an `Image` needs `.alt(t!(...))` and `.size(width, height)`",
note = "titles and alt texts are read by screen readers, and the size keeps the page from jumping while it loads"
)]
pub trait Complete: Component {}
fn policy<'a>(r: &Render<'a>) -> &'a Csp {
r.ctx.map(|c| c.csp()).unwrap_or(&EMPTY)
}
fn host_of(url: &str) -> &str {
let rest = url.get(8..).unwrap_or("");
rest.split(['/', '?', '#']).next().unwrap_or("")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EmbedPermission {
Fullscreen,
PictureInPicture,
Autoplay,
EncryptedMedia,
}
impl EmbedPermission {
fn feature(self) -> &'static str {
match self {
EmbedPermission::Fullscreen => "fullscreen",
EmbedPermission::PictureInPicture => "picture-in-picture",
EmbedPermission::Autoplay => "autoplay",
EmbedPermission::EncryptedMedia => "encrypted-media",
}
}
}
pub const EMBED_SANDBOX: &str = "allow-scripts allow-same-origin allow-presentation";
pub struct Embed<T = Given> {
url: String,
title: Text,
allow: Vec<EmbedPermission>,
aspect: (u32, u32),
poster: Option<String>,
_title: PhantomData<T>,
}
impl Embed<NoTitle> {
pub fn url(url: impl Into<String>) -> Self {
Embed {
url: url.into(),
title: Text::from(""),
allow: Vec::new(),
aspect: (16, 9),
poster: None,
_title: PhantomData,
}
}
pub fn title(self, title: impl Into<Text>) -> Embed<Given> {
Embed {
url: self.url,
title: title.into(),
allow: self.allow,
aspect: self.aspect,
poster: self.poster,
_title: PhantomData,
}
}
}
impl<T> Embed<T> {
pub fn allow(mut self, permissions: impl IntoIterator<Item = EmbedPermission>) -> Self {
for p in permissions {
if !self.allow.contains(&p) {
self.allow.push(p);
}
}
self
}
pub fn aspect(mut self, width: u32, height: u32) -> Self {
if width > 0 && height > 0 {
self.aspect = (width, height);
}
self
}
pub fn click_to_load(mut self, poster: impl Into<String>) -> Self {
self.poster = Some(poster.into());
self
}
}
impl Complete for Embed<Given> {}
impl<T> From<Embed<T>> for Node
where
Embed<T>: Complete + 'static,
{
fn from(e: Embed<T>) -> Self {
Node::new(e)
}
}
impl Component for Embed<Given> {
fn render(&self, out: &mut String, r: &Render) {
let title = r.text(&self.title);
let csp = policy(r);
let Some(url) = embed_url(&self.url).filter(|u| csp.allows_frame(u)) else {
let text = Text::key("zeyn.ui.embed.blocked").arg("title", self.title.clone());
Alert::warning(text).render(out, r);
return;
};
let allow: Vec<&str> = self.allow.iter().map(|p| p.feature()).collect();
let allow = if allow.is_empty() { String::new() } else { format!(r#" allow="{}""#, allow.join("; ")) };
let frame = format!(
r#"<iframe src="{}" title="{title}" sandbox="{EMBED_SANDBOX}"{allow} referrerpolicy="no-referrer" loading="lazy"></iframe>"#,
escape(&url)
);
let (w, h) = self.aspect;
out.push_str(&format!(
r#"<div class="z-embed" style="--z-aspect: {w} / {h}" data-testid="{}.embed">"#,
r.scope
));
match &self.poster {
None => out.push_str(&frame),
Some(poster) => {
let image = if !poster.is_empty() && csp.allows_image(poster) {
format!(r#"<img src="{}" alt="" decoding="async" loading="lazy">"#, escape(poster))
} else {
String::new()
};
out.push_str(&format!(
r#"<button type="button" class="z-embed-load" data-zeyn-embed-load>{image}<span class="z-embed-play" aria-hidden="true"></span><span class="z-embed-label"><strong>{}</strong><small>{}</small></span></button><template data-zeyn-embed>{frame}</template>"#,
r.text(&Text::key("zeyn.ui.embed.load").arg("title", self.title.clone())),
r.text(&Text::key("zeyn.ui.embed.from").arg("host", host_of(&url).to_string())),
));
}
}
out.push_str("</div>");
}
fn schema(&self) -> Json {
json!({
"type": "embed",
"url": embed_url(&self.url),
"title": self.title,
"allow": self.allow,
"aspect": [self.aspect.0, self.aspect.1],
"click_to_load": self.poster.is_some(),
})
}
}
pub struct Image<A = Given, S = Given> {
src: String,
alt: Text,
size: (u32, u32),
lazy: bool,
srcset: Vec<(String, String)>,
_parts: PhantomData<(A, S)>,
}
impl Image<NoAlt, NoSize> {
pub fn new(src: impl Into<String>) -> Self {
Image {
src: src.into(),
alt: Text::from(""),
size: (0, 0),
lazy: false,
srcset: Vec::new(),
_parts: PhantomData,
}
}
}
impl<A, S> Image<A, S> {
fn into_parts<B, T>(self) -> Image<B, T> {
Image {
src: self.src,
alt: self.alt,
size: self.size,
lazy: self.lazy,
srcset: self.srcset,
_parts: PhantomData,
}
}
pub fn lazy(mut self) -> Self {
self.lazy = true;
self
}
pub fn srcset<'a>(mut self, candidates: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
self.srcset.extend(candidates.into_iter().map(|(u, d)| (u.to_string(), d.to_string())));
self
}
}
impl<S> Image<NoAlt, S> {
pub fn alt(mut self, alt: impl Into<Text>) -> Image<Given, S> {
self.alt = alt.into();
self.into_parts()
}
}
impl<A> Image<A, NoSize> {
pub fn size(mut self, width: u32, height: u32) -> Image<A, Given> {
self.size = (width, height);
self.into_parts()
}
}
fn valid_descriptor(d: &str) -> bool {
match d.strip_suffix('x') {
Some(n) => {
!n.starts_with('.')
&& n.bytes().all(|b| b.is_ascii_digit() || b == b'.')
&& n.parse::<f32>().is_ok_and(|v| v > 0.0 && v <= 8.0)
}
None => {
d.strip_suffix('w').is_some_and(|n| !n.is_empty() && n.len() <= 5 && n.bytes().all(|b| b.is_ascii_digit()))
}
}
}
impl Complete for Image<Given, Given> {}
impl<A, S> From<Image<A, S>> for Node
where
Image<A, S>: Complete + 'static,
{
fn from(i: Image<A, S>) -> Self {
Node::new(i)
}
}
impl Component for Image<Given, Given> {
fn render(&self, out: &mut String, r: &Render) {
let csp = policy(r);
let alt = r.text(&self.alt);
let (w, h) = self.size;
if !csp.allows_image(&self.src) {
out.push_str(&format!(r#"<span class="z-image-blocked" data-testid="{}.image">{alt}</span>"#, r.scope));
return;
}
let candidates: Vec<String> = self
.srcset
.iter()
.filter(|(u, d)| !u.contains([' ', ',']) && csp.allows_image(u) && valid_descriptor(d))
.map(|(u, d)| format!("{} {d}", escape(u)))
.collect();
let srcset = if candidates.is_empty() {
String::new()
} else {
let sizes = if candidates.iter().any(|c| c.ends_with('w')) {
format!(r#" sizes="(max-width: {w}px) 100vw, {w}px""#)
} else {
String::new()
};
format!(r#" srcset="{}"{sizes}"#, candidates.join(", "))
};
let lazy = if self.lazy { r#" loading="lazy""# } else { "" };
out.push_str(&format!(
r#"<img class="z-image" src="{}" alt="{alt}" width="{w}" height="{h}" decoding="async"{lazy}{srcset} data-testid="{}.image">"#,
escape(&self.src),
r.scope
));
}
fn schema(&self) -> Json {
json!({ "type": "image", "src": self.src, "alt": self.alt, "width": self.size.0, "height": self.size.1, "lazy": self.lazy })
}
}
pub struct ExternalLink {
url: String,
text: Text,
user_content: bool,
new_tab: bool,
}
impl ExternalLink {
pub fn new(url: impl Into<String>, text: impl Into<Text>) -> Self {
ExternalLink { url: url.into(), text: text.into(), user_content: false, new_tab: false }
}
pub fn user_content(mut self) -> Self {
self.user_content = true;
self
}
pub fn new_tab(mut self) -> Self {
self.new_tab = true;
self
}
pub fn html(&self, r: &Render) -> String {
let text = r.text(&self.text);
let url = self.url.trim();
let lower = url.get(..8).unwrap_or(url).to_ascii_lowercase();
let web = (lower.starts_with("https://") || lower.starts_with("http://"))
&& url.len() > lower.find("//").unwrap_or(0) + 2
&& !url.chars().any(|c| c.is_whitespace() || c.is_control());
if !web {
return format!(r#"<span class="z-link-text">{text}</span>"#);
}
let rel = if self.user_content { "noopener noreferrer nofollow ugc" } else { "noopener noreferrer" };
let (target, spoken) = if self.new_tab {
(r#" target="_blank""#, "zeyn.ui.link.external_new_tab")
} else {
("", "zeyn.ui.link.external")
};
format!(
r#"<a class="z-link-external" href="{}" rel="{rel}"{target} data-testid="{}.link">{text}<span class="z-sr-only"> ({})</span><span class="z-link-external-icon" aria-hidden="true">↗</span></a>"#,
escape(url),
r.scope,
r.text(&Text::key(spoken))
)
}
}
impl Component for ExternalLink {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&self.html(r));
}
fn schema(&self) -> Json {
json!({ "type": "external_link", "url": self.url, "text": self.text, "user_content": self.user_content, "new_tab": self.new_tab })
}
}
super::layout::into_node!(ExternalLink);