use super::store;
use super::Kind;
use laterite_admin::editor::{Snippet, SnippetProperty, SnippetPropertyKind, SnippetSource};
use laterite_core::Db;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct Declaration {
pub code: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub properties: Vec<Property>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Property {
pub code: String,
pub title: String,
#[serde(default, rename = "type")]
pub kind: PropertyType,
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub options: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PropertyType {
#[default]
String,
Dropdown,
Checkbox,
}
impl Declaration {
pub fn from_settings(path: &str, settings: &serde_json::Value) -> Option<Self> {
let raw = settings.get("snippet")?;
match serde_json::from_value::<Declaration>(raw.clone()) {
Ok(d) if is_code(&d.code) => Some(d),
Ok(d) => {
tracing::warn!(
partial = path,
code = d.code,
"snippet code is not a code; skipped"
);
None
}
Err(e) => {
tracing::warn!(partial = path, error = %e, "snippet declaration does not parse; skipped");
None
}
}
}
pub fn to_admin(&self) -> Snippet {
Snippet {
code: self.code.clone(),
name: self.name.clone(),
description: self.description.clone(),
properties: self
.properties
.iter()
.map(|p| SnippetProperty {
code: p.code.clone(),
title: p.title.clone(),
kind: match p.kind {
PropertyType::String => SnippetPropertyKind::String,
PropertyType::Dropdown => SnippetPropertyKind::Dropdown,
PropertyType::Checkbox => SnippetPropertyKind::Checkbox,
},
default: p.default.clone(),
options: p.options.as_deref().map(parse_options).unwrap_or_default(),
})
.collect(),
}
}
}
fn is_code(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 100
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
pub fn parse_options(text: &str) -> Vec<(String, String)> {
text.split('|')
.map(str::trim)
.filter(|s| !s.is_empty())
.enumerate()
.map(|(i, item)| match item.split_once(':') {
Some((k, v)) => (k.trim().to_string(), v.trim().to_string()),
None => (i.to_string(), item.to_string()),
})
.collect()
}
#[derive(Debug, Default, Clone)]
pub struct Index {
pub by_code: HashMap<String, (String, Declaration)>,
}
impl Index {
pub fn add_partial(&mut self, path: &str, settings: &serde_json::Value) {
if let Some(decl) = Declaration::from_settings(path, settings) {
self.by_code
.insert(decl.code.clone(), (path.to_string(), decl));
}
}
pub fn get(&self, code: &str) -> Option<&(String, Declaration)> {
self.by_code.get(code)
}
pub fn declarations(&self) -> impl Iterator<Item = &Declaration> {
self.by_code.values().map(|(_, d)| d)
}
}
pub fn expand(
html: &str,
mut render: impl FnMut(&str, &serde_json::Map<String, serde_json::Value>) -> Option<String>,
) -> String {
const OPEN: &str = "<figure";
const CLOSE: &str = "</figure>";
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(at) = rest.find(OPEN) {
let after = &rest[at + OPEN.len()..];
let Some(gt) = after.find('>') else { break };
let attrs = &after[..gt];
let Some(code) = attr(attrs, "data-snippet") else {
out.push_str(&rest[..at + OPEN.len() + gt + 1]);
rest = &after[gt + 1..];
continue;
};
let body_and_rest = &after[gt + 1..];
let end = body_and_rest
.find(CLOSE)
.map(|e| e + CLOSE.len())
.unwrap_or(0);
let props: serde_json::Map<String, serde_json::Value> = attr(attrs, "data-properties")
.and_then(|p| serde_json::from_str(&p).ok())
.unwrap_or_default();
out.push_str(&rest[..at]);
match render(&code, &props) {
Some(rendered) => out.push_str(&rendered),
None => out.push_str(&format!("<!-- snippet {code}: not found -->")),
}
rest = &body_and_rest[end..];
}
out.push_str(rest);
out
}
fn attr(attrs: &str, name: &str) -> Option<String> {
let key = format!("{name}=");
let at = attrs.find(&key)?;
let value = &attrs[at + key.len()..];
let quote = value.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let inner = &value[1..];
let end = inner.find(quote)?;
Some(
inner[..end]
.replace(""", "\"")
.replace(""", "\"")
.replace("'", "'")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&"),
)
}
pub struct Source;
#[laterite_core::strata::async_trait]
impl SnippetSource for Source {
async fn list(&self, db: &Db) -> Vec<Snippet> {
let Ok(Some(theme)) = super::active_theme(db).await else {
return Vec::new();
};
let Ok(chain) = store::chain(db, &theme.code).await else {
return Vec::new();
};
let mut index = Index::default();
for theme in chain.iter().rev() {
if let Ok(templates) = store::list_templates(db, theme.id, Some(Kind::Partial)).await {
for t in templates {
index.add_partial(&t.path, &t.settings);
}
}
}
index.declarations().map(Declaration::to_admin).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_declaration_reads_from_settings_and_maps_to_the_menu() {
let settings = serde_json::json!({
"snippet": {
"code": "youtube",
"name": "YouTube video",
"properties": [
{ "code": "url", "title": "Video URL" },
{ "code": "size", "title": "Size", "type": "dropdown", "options": "sm:Small | lg:Large", "default": "sm" },
{ "code": "autoplay", "title": "Autoplay", "type": "checkbox" }
]
}
});
let decl = Declaration::from_settings("snippets/youtube.htm", &settings).unwrap();
let menu = decl.to_admin();
assert_eq!(menu.code, "youtube");
assert_eq!(menu.properties.len(), 3);
assert_eq!(menu.properties[1].kind, SnippetPropertyKind::Dropdown);
assert_eq!(
menu.properties[1].options,
vec![
("sm".to_string(), "Small".to_string()),
("lg".to_string(), "Large".to_string())
]
);
assert!(Declaration::from_settings("x", &serde_json::json!({})).is_none());
assert!(Declaration::from_settings(
"x",
&serde_json::json!({"snippet": {"code": "bad code", "name": "n"}})
)
.is_none());
}
#[test]
fn figures_expand_and_a_missing_one_leaves_a_comment() {
let html = r#"<p>a</p><figure data-snippet="map" data-properties="{"zoom":3}"></figure><figure class="plain"><img src="x"></figure><figure data-snippet="gone"></figure>"#;
let out = expand(html, |code, props| match code {
"map" => Some(format!("<div>map {}</div>", props["zoom"])),
_ => None,
});
assert_eq!(
out,
r#"<p>a</p><div>map 3</div><figure class="plain"><img src="x"></figure><!-- snippet gone: not found -->"#
);
}
#[test]
fn bare_options_take_their_index() {
assert_eq!(
parse_options("US | Canada"),
vec![
("0".to_string(), "US".to_string()),
("1".to_string(), "Canada".to_string())
]
);
}
}