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 {}
pub(super) 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>,
notice: Option<Text>,
remember: Option<(&'static str, u64)>,
_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,
notice: None,
remember: 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,
notice: self.notice,
remember: self.remember,
_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
}
pub fn notice(mut self, notice: impl Into<Text>) -> Self {
self.notice = Some(notice.into());
self.poster
.get_or_insert_with(String::new);
self
}
pub fn remember(mut self, scope: &'static str, time: std::time::Duration) -> Self {
assert!(
valid_scope(scope),
"Embed::remember: scope `{scope}` is not valid. Use 1 to 40 ASCII letters, digits, `-` or `_`, such as \"external-video\"; embeds that share a notice share a scope."
);
assert!(
time.as_secs() >= 1 && time <= MAX_EMBED_CONSENT,
"Embed::remember: time {time:?} for scope `{scope}` is not valid. Use 1 second to 30 days (zeyn::ui::MAX_EMBED_CONSENT), such as Duration::from_secs(24 * 60 * 60)."
);
self.remember = Some((scope, time.as_millis() as u64));
self.poster
.get_or_insert_with(String::new);
self
}
}
pub const MAX_EMBED_CONSENT: std::time::Duration =
std::time::Duration::from_secs(30 * 24 * 60 * 60);
const fn valid_scope(scope: &str) -> bool {
let b = scope.as_bytes();
if b.is_empty() || b.len() > 40 {
return false;
}
let mut i = 0;
while i < b.len() {
if !(b[i].is_ascii_alphanumeric() || b[i] == b'-' || b[i] == b'_') {
return false;
}
i += 1;
}
true
}
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;
let remember = self
.remember
.map(|(s, ms)| {
format!(
r#" data-zeyn-embed-remember="{}" data-zeyn-embed-for="{ms}""#,
escape(s)
)
})
.unwrap_or_default();
out.push_str(&format!(
r#"<div class="z-embed" style="--z-aspect: {w} / {h}"{remember} 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()
};
let notice = match &self.notice {
Some(n) => format!(r#"<span class="z-embed-notice">{}</span>"#, r.text(n)),
None => 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">{notice}<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(),
"notice": self.notice,
"remember": self.remember.map(|(scope, ms)| json!({ "scope": scope, "ms": ms })),
})
}
}
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);