use std::sync::OnceLock;
mod common;
pub mod folder_list;
use common::path_extension_lower;
pub use common::{file_stem, html_escape_attr};
pub const CLASS_EMBED: &str = "moss-embed";
pub const CLASS_EMBED_IFRAME: &str = "moss-embed-iframe";
pub const CLASS_EMBED_PDF: &str = "moss-embed-pdf";
pub const CLASS_EMBED_AUDIO: &str = "moss-embed-audio";
pub const CLASS_EMBED_VIDEO: &str = "moss-embed-video";
pub const CLASS_EMBED_NOTEBOOK: &str = "moss-embed-notebook";
pub const CLASS_EMBED_3D: &str = "moss-embed-3d";
pub const CLASS_EMBED_TABLE: &str = "moss-embed-table";
pub const MARKER_MARKDOWN: &str = "moss-embed";
pub const MARKER_IPYNB: &str = "moss-embed-ipynb";
pub const MARKER_TABLE: &str = "moss-embed-table";
pub use folder_list::{MARKER_END, MARKER_FOLDER_LIST};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedEmbed<'a> {
pub resolved_path: &'a str,
pub from_path: &'a str,
pub pinned_url: &'a str,
pub query: Option<&'a str>,
pub section: Option<&'a str>,
pub alias: Option<&'a str>,
pub width: Option<&'static str>,
pub attrs: Option<crate::ast::attrs::AttrBlock>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum RenderedEmbed {
Inline(String),
Html(String),
Deferred { marker: String },
}
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Dim {
Px(u32),
Percent(f32),
Vh(f32),
}
impl Dim {
pub fn to_css(self) -> String {
match self {
Dim::Px(n) => format!("{}px", n),
Dim::Percent(v) => {
if v.fract() == 0.0 {
format!("{}%", v as i64)
} else {
format!("{}%", v)
}
}
Dim::Vh(v) => {
if v.fract() == 0.0 {
format!("{}vh", v as i64)
} else {
format!("{}vh", v)
}
}
}
}
fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some(rest) = s.strip_suffix('%') {
return rest.trim().parse::<f32>().ok().map(Dim::Percent);
}
if let Some(rest) = s.strip_suffix("vh") {
return rest.trim().parse::<f32>().ok().map(Dim::Vh);
}
if let Some(rest) = s.strip_suffix("px") {
return rest.trim().parse::<u32>().ok().map(Dim::Px);
}
s.parse::<u32>().ok().map(Dim::Px)
}
}
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Sizing {
Width(Dim),
Box(Dim, Dim),
}
impl Sizing {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some((w, h)) = s.split_once('x') {
let wd = Dim::parse(w)?;
let hd = Dim::parse(h)?;
return Some(Sizing::Box(wd, hd));
}
Dim::parse(s).map(Sizing::Width)
}
}
pub trait EmbedRenderer: std::fmt::Debug + Send + Sync {
fn extensions(&self) -> &[&'static str];
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed;
fn head_assets(&self) -> &[&'static str] {
&[]
}
}
fn registry() -> &'static [&'static dyn EmbedRenderer] {
static INIT: OnceLock<Vec<&'static dyn EmbedRenderer>> = OnceLock::new();
INIT.get_or_init(|| {
vec![
&MarkdownEmbedRenderer as &'static dyn EmbedRenderer,
&IframeRenderer as &'static dyn EmbedRenderer,
&PdfRenderer as &'static dyn EmbedRenderer,
&AudioRenderer as &'static dyn EmbedRenderer,
&VideoRenderer as &'static dyn EmbedRenderer,
&NotebookRenderer as &'static dyn EmbedRenderer,
&ModelViewerRenderer as &'static dyn EmbedRenderer,
&TableRenderer as &'static dyn EmbedRenderer,
]
})
}
pub fn lookup_renderer(ext: &str) -> Option<&'static dyn EmbedRenderer> {
if ext.is_empty() {
return None;
}
registry()
.iter()
.copied()
.find(|r| r.extensions().iter().any(|e| e.eq_ignore_ascii_case(ext)))
}
use crate::heading::anchor::obsidian_heading_anchor;
use super::title_params::TitleParams;
pub(crate) const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "svg", "webp", "avif"];
fn align_keyword(side: crate::media::AlignSide) -> &'static str {
match side {
crate::media::AlignSide::Left => "left",
crate::media::AlignSide::Right => "right",
}
}
fn add_class_dedup(acc: &mut Vec<String>, class: &str) {
if !acc.iter().any(|c| c == class) {
acc.push(class.to_string());
}
}
pub(super) fn fold_attrs_into_params(
block: &crate::ast::attrs::AttrBlock,
params: &mut TitleParams,
) {
let mut classes: Vec<String> = Vec::new();
let mut consumed_class_kv = false;
for class in &block.classes {
if let Some(side) = crate::media::AlignSide::from_keyword(class) {
params.insert("align", align_keyword(side));
} else {
add_class_dedup(&mut classes, class);
}
}
if let Some(w) = block.width {
params.insert("data-width", w);
}
for (k, v) in &block.kvs {
if k == "class" {
consumed_class_kv = true;
for c in v.split_whitespace() {
if let Some(side) = crate::media::AlignSide::from_keyword(c) {
params.insert("align", align_keyword(side));
} else {
add_class_dedup(&mut classes, c);
}
}
}
}
if !classes.is_empty() {
params.insert("classes", classes.join(" "));
}
for (k, v) in &block.kvs {
if consumed_class_kv && k == "class" {
continue;
}
params.insert(k.clone(), v.clone());
}
}
fn markdown_escape_alt(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
}
fn render_link_markdown(
embed: &ParsedEmbed<'_>,
kind: &'static str,
extra: impl FnOnce(&ParsedEmbed<'_>, &mut TitleParams),
) -> String {
let url = embed.pinned_url;
let mut params = TitleParams::default();
params.insert("kind", kind);
if let Some(w) = embed.width {
params.insert("data-width", w);
}
extra(embed, &mut params);
if let Some(block) = &embed.attrs {
fold_attrs_into_params(block, &mut params);
}
let _ = params;
let name = file_stem(embed.resolved_path);
format!("[{}]({})", markdown_escape_alt(&name), url)
}
#[derive(Debug)]
pub struct MarkdownEmbedRenderer;
impl EmbedRenderer for MarkdownEmbedRenderer {
fn extensions(&self) -> &[&'static str] {
&["md"]
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
let anchor = build_embed_anchor(embed.section);
RenderedEmbed::Deferred {
marker: format!(
"<!-- {}:{}{} -->",
MARKER_MARKDOWN, embed.resolved_path, anchor
),
}
}
}
fn build_embed_anchor(section: Option<&str>) -> String {
match section {
None => String::new(),
Some(s) if s.is_empty() => String::new(),
Some(s) => {
if s.starts_with('^') {
format!("#{}", s)
} else {
format!("#{}", obsidian_heading_anchor(s))
}
}
}
}
#[derive(Debug)]
pub struct IframeRenderer;
impl EmbedRenderer for IframeRenderer {
fn extensions(&self) -> &[&'static str] {
&["html", "htm"]
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
RenderedEmbed::Inline(render_link_markdown(embed, "iframe", iframe_extra_params))
}
}
fn iframe_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
if let Some(q) = embed.query {
params.insert("query", q);
}
if let Some(f) = embed.section {
params.insert("fragment", f);
}
let Some(alias) = embed.alias else {
return;
};
match Sizing::parse(alias) {
Some(Sizing::Width(w)) => {
params.insert("width", w.to_css());
}
Some(Sizing::Box(w, h)) => {
params.insert("width", w.to_css());
params.insert("height", h.to_css());
}
None => {
params.insert("title", alias);
}
}
}
#[derive(Debug)]
pub struct PdfRenderer;
impl EmbedRenderer for PdfRenderer {
fn extensions(&self) -> &[&'static str] {
&["pdf"]
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
RenderedEmbed::Inline(render_link_markdown(embed, "pdf", pdf_extra_params))
}
}
fn pdf_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
if let Some(q) = embed.query {
params.insert("query", q);
}
if let Some(f) = embed.section {
params.insert("fragment", f);
}
if let Some(alias) = embed.alias {
match Sizing::parse(alias) {
Some(Sizing::Width(w)) => {
params.insert("width", w.to_css());
}
Some(Sizing::Box(w, h)) => {
params.insert("width", w.to_css());
params.insert("height", h.to_css());
}
None => {}
}
}
}
const AUDIO_EXTENSIONS: &[&str] = &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac"];
#[derive(Debug)]
pub struct AudioRenderer;
impl EmbedRenderer for AudioRenderer {
fn extensions(&self) -> &[&'static str] {
AUDIO_EXTENSIONS
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
RenderedEmbed::Inline(render_link_markdown(embed, "audio", audio_extra_params))
}
}
fn audio_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
let ext = path_extension_lower(embed.resolved_path);
if !ext.is_empty() {
params.insert("ext", ext);
}
}
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "m4v"];
#[derive(Debug)]
pub struct VideoRenderer;
impl EmbedRenderer for VideoRenderer {
fn extensions(&self) -> &[&'static str] {
VIDEO_EXTENSIONS
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
RenderedEmbed::Inline(render_link_markdown(embed, "video", video_extra_params))
}
}
fn video_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
if let Some(alias) = embed.alias {
match Sizing::parse(alias) {
Some(Sizing::Width(w)) => {
params.insert("width", w.to_css());
}
Some(Sizing::Box(w, h)) => {
params.insert("width", w.to_css());
params.insert("height", h.to_css());
}
None => {}
}
}
}
#[derive(Debug)]
pub struct NotebookRenderer;
impl EmbedRenderer for NotebookRenderer {
fn extensions(&self) -> &[&'static str] {
&["ipynb"]
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
let target = match embed.query {
Some(q) => format!("{}?{}", embed.resolved_path, q),
None => embed.resolved_path.to_string(),
};
RenderedEmbed::Deferred {
marker: format!("<!-- {}:{} -->", MARKER_IPYNB, target),
}
}
}
const MODEL_VIEWER_SCRIPT: &str = "<script type=\"module\" src=\"https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js\"></script>";
#[derive(Debug)]
pub struct ModelViewerRenderer;
impl EmbedRenderer for ModelViewerRenderer {
fn extensions(&self) -> &[&'static str] {
&["glb", "gltf"]
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
RenderedEmbed::Inline(render_link_markdown(embed, "3d", model_viewer_extra_params))
}
fn head_assets(&self) -> &[&'static str] {
&[MODEL_VIEWER_SCRIPT]
}
}
fn model_viewer_extra_params(embed: &ParsedEmbed<'_>, params: &mut TitleParams) {
if let Some(alias) = embed.alias {
match Sizing::parse(alias) {
Some(Sizing::Width(w)) => {
params.insert("width", w.to_css());
}
Some(Sizing::Box(w, h)) => {
params.insert("width", w.to_css());
params.insert("height", h.to_css());
}
None => {}
}
}
}
#[derive(Debug)]
pub struct TableRenderer;
impl EmbedRenderer for TableRenderer {
fn extensions(&self) -> &[&'static str] {
&["csv", "tsv"]
}
fn render(&self, embed: &ParsedEmbed<'_>) -> RenderedEmbed {
RenderedEmbed::Deferred {
marker: format!("<!-- {}:{} -->", MARKER_TABLE, embed.resolved_path),
}
}
}
#[cfg(test)]
#[path = "embed_renderer_tests.rs"]
mod tests;