Skip to main content

laterite_admin/
editor.rs

1//! What a module adds to the editing surfaces: commands an author inserts
2//! from the toolbar's Insert menu, and snippets (configurable blocks a theme
3//! or module defines) placed into rich text or markdown. Both are data: a
4//! module never injects a script into an editor.
5//!
6//! A command is registered at boot through
7//! [`AdminRegistry::add_editor_command`](crate::AdminRegistry); snippets come
8//! from a [`SnippetSource`] asked at request time, because a theme's partials
9//! change while the application runs. The surfaces read both from
10//! `GET <admin>/editor/menu?surface=<key>`.
11
12use laterite_core::{Db, Text};
13use serde::Serialize;
14use std::sync::Arc;
15
16/// An editing surface a command or snippet targets.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Surface {
19    /// The `richeditor` field: commands insert HTML.
20    Rich,
21    /// The `markdown` field: commands insert markdown text.
22    Markdown,
23}
24
25impl Surface {
26    /// The key the surface's island sends: `richeditor` or `markdown`.
27    pub fn key(self) -> &'static str {
28        match self {
29            Self::Rich => "richeditor",
30            Self::Markdown => "markdown",
31        }
32    }
33
34    pub(crate) fn parse(key: &str) -> Option<Self> {
35        match key {
36            "richeditor" => Some(Self::Rich),
37            "markdown" => Some(Self::Markdown),
38            _ => None,
39        }
40    }
41}
42
43/// What a command puts at the caret: HTML for the rich surface, text for
44/// markdown. A command declared with the wrong kind for its surface is dropped
45/// with a boot log line.
46#[derive(Debug, Clone)]
47pub enum Insert {
48    Html(String),
49    Text(String),
50}
51
52/// An Insert-menu entry a module contributes to one surface.
53#[derive(Debug, Clone)]
54pub struct EditorCommand {
55    pub surface: Surface,
56    /// Stable within the surface; a later contribution with the same key wins.
57    pub key: String,
58    pub label: Text,
59    pub insert: Insert,
60}
61
62impl EditorCommand {
63    pub fn new(
64        surface: Surface,
65        key: impl Into<String>,
66        label: impl Into<Text>,
67        insert: Insert,
68    ) -> Self {
69        Self {
70            surface,
71            key: key.into(),
72            label: label.into(),
73            insert,
74        }
75    }
76}
77
78/// What a snippet property holds, and how its dialog asks for it.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "lowercase")]
81pub enum SnippetPropertyKind {
82    /// A line of text.
83    String,
84    /// One of `options`.
85    Dropdown,
86    /// A yes or no.
87    Checkbox,
88}
89
90/// A property a snippet's dialog asks for, passed to the snippet by `code`.
91#[derive(Debug, Clone, Serialize)]
92pub struct SnippetProperty {
93    pub code: String,
94    pub title: String,
95    pub kind: SnippetPropertyKind,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub default: Option<serde_json::Value>,
98    /// `(value, label)` pairs for a dropdown.
99    #[serde(skip_serializing_if = "Vec::is_empty")]
100    pub options: Vec<(String, String)>,
101}
102
103/// A configurable block an author inserts, expanded by the site at render.
104#[derive(Debug, Clone, Serialize)]
105pub struct Snippet {
106    pub code: String,
107    pub name: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub description: Option<String>,
110    pub properties: Vec<SnippetProperty>,
111}
112
113/// Lists the snippets available now. A theme module implements it over its
114/// partials; a module whose snippets are fixed returns a constant list.
115#[laterite_core::strata::async_trait]
116pub trait SnippetSource: Send + Sync {
117    async fn list(&self, db: &Db) -> Vec<Snippet>;
118}
119
120/// A registered snippet source. See [`AdminRegistry::add_snippet_source`](crate::AdminRegistry).
121#[derive(Clone)]
122pub struct SnippetSourceReg(pub Arc<dyn SnippetSource>);
123
124/// The menu a surface shows: its commands (localized) and, for rich text and
125/// markdown, the snippets.
126#[derive(Debug, Default, Serialize)]
127pub struct Menu {
128    pub commands: Vec<MenuCommand>,
129    pub snippets: Vec<Snippet>,
130}
131
132#[derive(Debug, Serialize)]
133pub struct MenuCommand {
134    pub key: String,
135    pub label: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub html: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub text: Option<String>,
140}
141
142/// The commands for one surface, last registration of a key winning.
143pub(crate) fn commands<'a>(
144    all: impl Iterator<Item = &'a EditorCommand>,
145    surface: Surface,
146) -> Vec<&'a EditorCommand> {
147    let mut out: Vec<&EditorCommand> = Vec::new();
148    for cmd in all.filter(|c| c.surface == surface) {
149        let fits = matches!(
150            (surface, &cmd.insert),
151            (Surface::Rich, Insert::Html(_)) | (Surface::Markdown, Insert::Text(_))
152        );
153        if !fits {
154            tracing::warn!(
155                key = cmd.key,
156                surface = surface.key(),
157                "editor command inserts the wrong kind for its surface; dropped"
158            );
159            continue;
160        }
161        if let Some(at) = out.iter().position(|c| c.key == cmd.key) {
162            out[at] = cmd;
163        } else {
164            out.push(cmd);
165        }
166    }
167    out
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn commands_are_filtered_by_surface_and_kind_and_deduped_by_key() {
176        let all = [
177            EditorCommand::new(Surface::Rich, "hr", "Rule", Insert::Html("<hr>".into())),
178            EditorCommand::new(Surface::Rich, "bad", "Bad", Insert::Text("x".into())),
179            EditorCommand::new(Surface::Markdown, "hr", "Rule", Insert::Text("---".into())),
180            EditorCommand::new(
181                Surface::Rich,
182                "hr",
183                "Rule 2",
184                Insert::Html("<hr class=a>".into()),
185            ),
186        ];
187        let rich = commands(all.iter(), Surface::Rich);
188        assert_eq!(rich.len(), 1);
189        assert!(matches!(&rich[0].insert, Insert::Html(h) if h == "<hr class=a>"));
190        assert_eq!(commands(all.iter(), Surface::Markdown).len(), 1);
191    }
192}