use serde::{Deserialize, Serialize};
use super::PluginId;
use crate::error::DiaryxError;
use crate::frontmatter;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct PluginManifest {
pub id: PluginId,
pub name: String,
pub version: String,
pub description: String,
pub capabilities: Vec<PluginCapability>,
pub ui: Vec<UiContribution>,
#[serde(default)]
pub cli: Vec<CliCommand>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum PluginCapability {
FileEvents,
WorkspaceEvents,
CrdtCommands,
SyncTransport,
CustomCommands {
commands: Vec<String>,
},
EditorExtension,
MediaTranscoder {
conversions: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(tag = "slot")]
pub enum UiContribution {
SettingsTab {
id: String,
label: String,
icon: Option<String>,
fields: Vec<SettingsField>,
#[serde(default)]
component: Option<ComponentRef>,
},
SidebarTab {
id: String,
label: String,
icon: Option<String>,
side: SidebarSide,
component: ComponentRef,
},
CommandPaletteItem {
id: String,
label: String,
group: Option<String>,
plugin_command: String,
},
CommandPalette {
id: String,
label: Option<String>,
component: ComponentRef,
},
ContextMenu {
id: String,
label: Option<String>,
target: ContextMenuTarget,
component: ComponentRef,
},
StatusBarItem {
id: String,
label: String,
position: StatusBarPosition,
plugin_command: Option<String>,
},
WorkspaceProvider {
id: String,
label: String,
description: Option<String>,
},
EditorExtension {
extension_id: String,
node_type: EditorNodeType,
markdown: Box<MarkdownSyntax>,
render_export: Option<String>,
edit_mode: Option<EditMode>,
#[serde(default)]
iframe_component_id: Option<String>,
css: Option<String>,
#[serde(default)]
insert_command: Box<Option<InsertCommand>>,
#[serde(default)]
keyboard_shortcut: Option<String>,
#[serde(default)]
click_behavior: Option<ClickBehavior>,
#[serde(default)]
html_tag: Option<String>,
#[serde(default)]
base_css_class: Option<String>,
#[serde(default)]
attributes: Option<Vec<MarkAttribute>>,
#[serde(default)]
toolbar: Box<Option<MarkToolbar>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct MarkToolbar {
pub icon: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum SidebarSide {
Left,
Right,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum StatusBarPosition {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum ContextMenuTarget {
LeftSidebarTree,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum EditorNodeType {
InlineAtom,
BlockAtom,
InlineMark,
Builtin {
host_extension_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct MarkdownSyntax {
pub level: MarkdownLevel,
pub open: String,
pub close: String,
#[serde(default)]
pub attribute_syntax: Option<MarkdownAttributeSyntax>,
#[serde(default)]
pub single_line: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct MarkdownAttributeSyntax {
pub attribute: String,
pub open: String,
pub close: String,
#[serde(default = "default_after_open")]
pub position: String,
}
fn default_after_open() -> String {
"after_open".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct MarkAttribute {
pub name: String,
pub default: String,
pub html_attribute: String,
#[serde(default)]
pub valid_values: Vec<String>,
#[serde(default)]
pub css_class_prefix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum MarkdownLevel {
Inline,
Block,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum EditMode {
Popover,
SourceToggle,
Iframe,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum InsertCommandPlacement {
#[default]
Picker,
PickerAndStylePicker,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct InsertCommand {
pub label: String,
pub icon: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub placement: InsertCommandPlacement,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum ClickBehavior {
ToggleClass {
hidden_class: String,
revealed_class: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(tag = "type")]
pub enum ComponentRef {
Builtin {
component_id: String,
},
Declarative {
fields: Vec<SettingsField>,
},
Iframe {
component_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
#[serde(tag = "type")]
pub enum SettingsField {
Text {
key: String,
label: String,
description: Option<String>,
#[serde(default)]
placeholder: Option<String>,
},
Password {
key: String,
label: String,
description: Option<String>,
#[serde(default)]
placeholder: Option<String>,
},
Toggle {
key: String,
label: String,
description: Option<String>,
},
Select {
key: String,
label: String,
options: Vec<SelectOption>,
description: Option<String>,
},
Number {
key: String,
label: String,
min: Option<f64>,
max: Option<f64>,
},
Section {
label: String,
description: Option<String>,
},
Button {
label: String,
command: String,
#[serde(default)]
variant: Option<String>,
},
HostActionButton {
label: String,
action_type: String,
#[serde(default)]
variant: Option<String>,
},
AuthStatus {
label: String,
description: Option<String>,
},
UpgradeBanner {
feature: String,
description: Option<String>,
},
Conditional {
condition: String,
fields: Vec<SettingsField>,
},
HostWidget {
widget_id: String,
#[serde(default)]
sign_in_action: Option<HostAction>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct HostAction {
pub action_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct SelectOption {
pub value: String,
pub label: String,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct CliCommand {
pub name: String,
pub about: String,
#[serde(default)]
pub long_about: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub args: Vec<CliArg>,
#[serde(default)]
pub subcommands: Vec<CliCommand>,
#[serde(default)]
pub command_name: Option<String>,
#[serde(default = "default_true")]
pub requires_workspace: bool,
#[serde(default)]
pub native_handler: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub struct CliArg {
pub name: String,
pub help: String,
#[serde(default)]
pub value_type: CliArgType,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default_value: Option<String>,
#[serde(default)]
pub short: Option<char>,
#[serde(default)]
pub long: Option<String>,
#[serde(default)]
pub is_flag: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export, export_to = "bindings/"))]
pub enum CliArgType {
#[default]
String,
Integer,
Float,
Boolean,
Path,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PluginArtifact {
pub url: String,
pub sha256: String,
pub size: u64,
pub published_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MarketplaceEntry {
pub id: String,
pub name: String,
pub version: String,
pub summary: String,
pub description: String,
pub author: String,
pub license: String,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
pub artifact: PluginArtifact,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub screenshots: Vec<String>,
#[serde(default)]
pub requested_permissions: Option<serde_json::Value>,
#[serde(default)]
pub protocol_version: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceRegistry {
pub schema_version: u64,
pub generated_at: String,
pub plugins: Vec<MarketplaceEntry>,
#[serde(skip)]
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginWorkspaceMetadata {
pub id: String,
pub name: String,
pub version: String,
pub summary: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub capabilities: Vec<String>,
pub artifact: PluginArtifact,
#[serde(default)]
pub ui: Option<serde_json::Value>,
#[serde(default)]
pub cli: Option<serde_json::Value>,
#[serde(default)]
pub requested_permissions: Option<serde_json::Value>,
#[serde(default)]
pub protocol_version: Option<u32>,
#[serde(skip)]
pub body: String,
}
fn yaml_to_json(yaml: &crate::yaml_value::YamlValue) -> Result<serde_json::Value, DiaryxError> {
Ok(serde_json::Value::from(yaml.clone()))
}
impl MarketplaceRegistry {
pub fn from_markdown(content: &str) -> Result<Self, DiaryxError> {
let parsed = frontmatter::parse(content)?;
let schema_version = parsed
.frontmatter
.get("schema_version")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
DiaryxError::Validation(
"Registry missing or invalid schema_version (expected 2)".to_string(),
)
})?;
if schema_version != 2 {
return Err(DiaryxError::Validation(format!(
"Unsupported registry schema_version: {schema_version} (expected 2)"
)));
}
let generated_at = parsed
.frontmatter
.get("generated_at")
.and_then(|v| v.as_str())
.ok_or_else(|| DiaryxError::Validation("Registry missing generated_at".to_string()))?
.to_string();
let plugins_yaml = parsed
.frontmatter
.get("plugins")
.ok_or_else(|| DiaryxError::Validation("Registry missing plugins array".to_string()))?;
let plugins_json = yaml_to_json(plugins_yaml)?;
let plugins: Vec<MarketplaceEntry> = serde_json::from_value(plugins_json)
.map_err(|e| DiaryxError::Validation(format!("Failed to parse plugins: {e}")))?;
for plugin in &plugins {
validate_marketplace_entry(plugin)?;
}
Ok(MarketplaceRegistry {
schema_version,
generated_at,
plugins,
body: parsed.body,
})
}
}
impl PluginWorkspaceMetadata {
pub fn from_markdown(content: &str) -> Result<Self, DiaryxError> {
let parsed = frontmatter::parse(content)?;
let fm = &parsed.frontmatter;
let id = fm
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| DiaryxError::Validation("Plugin workspace missing 'id'".to_string()))?
.to_string();
let name = fm
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| DiaryxError::Validation("Plugin workspace missing 'title'".to_string()))?
.to_string();
let version = fm
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| {
DiaryxError::Validation("Plugin workspace missing 'version'".to_string())
})?
.to_string();
let summary = fm
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let author = fm
.get("author")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let license = fm
.get("license")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let repository = fm
.get("repository")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let categories = yaml_string_array(fm.get("categories"));
let tags = yaml_string_array(fm.get("tags"));
let capabilities = yaml_string_array(fm.get("capabilities"));
let artifact_yaml = fm.get("artifact").ok_or_else(|| {
DiaryxError::Validation("Plugin workspace missing 'artifact'".to_string())
})?;
let artifact_json = yaml_to_json(artifact_yaml)?;
let artifact: PluginArtifact = serde_json::from_value(artifact_json)
.map_err(|e| DiaryxError::Validation(format!("Failed to parse artifact: {e}")))?;
let ui = fm.get("ui").map(yaml_to_json).transpose()?;
let cli = fm.get("cli").map(yaml_to_json).transpose()?;
let requested_permissions = fm
.get("requested_permissions")
.map(yaml_to_json)
.transpose()?;
let protocol_version = fm
.get("protocol_version")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
Ok(PluginWorkspaceMetadata {
id,
name,
version,
summary,
author,
license,
repository,
categories,
tags,
capabilities,
artifact,
ui,
cli,
requested_permissions,
protocol_version,
body: parsed.body,
})
}
pub fn to_marketplace_entry(&self) -> MarketplaceEntry {
MarketplaceEntry {
id: self.id.clone(),
name: self.name.clone(),
version: self.version.clone(),
summary: self.summary.clone(),
description: self.body.trim().to_string(),
author: self.author.clone().unwrap_or_default(),
license: self.license.clone().unwrap_or_default(),
repository: self.repository.clone(),
categories: self.categories.clone(),
tags: self.tags.clone(),
artifact: self.artifact.clone(),
capabilities: self.capabilities.clone(),
icon: None,
screenshots: Vec::new(),
requested_permissions: self.requested_permissions.clone(),
protocol_version: self.protocol_version,
}
}
}
fn yaml_string_array(value: Option<&crate::yaml_value::YamlValue>) -> Vec<String> {
match value {
Some(crate::yaml_value::YamlValue::Sequence(seq)) => seq
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
fn validate_marketplace_entry(entry: &MarketplaceEntry) -> Result<(), DiaryxError> {
if entry.id.trim().is_empty() {
return Err(DiaryxError::Validation(
"Marketplace entry has empty id".to_string(),
));
}
if entry.version.trim().is_empty() {
return Err(DiaryxError::Validation(format!(
"Marketplace entry '{}' has empty version",
entry.id
)));
}
if entry.artifact.url.trim().is_empty() {
return Err(DiaryxError::Validation(format!(
"Marketplace entry '{}' has empty artifact.url",
entry.id
)));
}
if entry.artifact.sha256.trim().is_empty() {
return Err(DiaryxError::Validation(format!(
"Marketplace entry '{}' has empty artifact.sha256",
entry.id
)));
}
if entry.artifact.size == 0 {
return Err(DiaryxError::Validation(format!(
"Marketplace entry '{}' has artifact.size=0",
entry.id
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_REGISTRY_MD: &str = r#"---
title: "Diaryx Plugin Registry"
description: "Official plugin directory"
generated_at: "2026-03-03T00:00:00Z"
schema_version: 2
plugins:
- id: "diaryx.sync"
name: "Sync"
version: "1.2.3"
summary: "Realtime multi-device sync"
description: "Full description of sync plugin"
author: "Diaryx Team"
license: "PolyForm Shield 1.0.0"
repository: "https://github.com/diaryx-org/diaryx-sync"
categories: ["sync", "collaboration"]
tags: ["sync", "crdt", "realtime"]
artifact:
url: "https://app.diaryx.org/cdn/plugins/artifacts/diaryx.sync/1.2.3/abc123.wasm"
sha256: "abc123"
size: 2048000
published_at: "2026-03-03T00:00:00Z"
capabilities: ["sync_transport"]
icon: null
screenshots: []
requested_permissions: null
---
# Diaryx Plugin Registry
Browse and install plugins for Diaryx.
"#;
const SAMPLE_PLUGIN_README: &str = r#"---
title: "Sync"
description: "Realtime multi-device sync"
id: "diaryx.sync"
version: "1.2.3"
author: "Diaryx Team"
license: "PolyForm Shield 1.0.0"
repository: "https://github.com/diaryx-org/diaryx-sync"
categories: ["sync", "collaboration"]
tags: ["sync", "crdt", "realtime"]
capabilities: ["sync_transport", "crdt_commands"]
artifact:
url: "https://app.diaryx.org/cdn/plugins/artifacts/diaryx.sync/1.2.3/abc123.wasm"
sha256: "abc123"
size: 2048000
published_at: "2026-03-03T00:00:00Z"
ui:
- slot: WorkspaceProvider
id: diaryx.sync
label: "Diaryx Cloud"
cli:
- name: sync
about: "Sync workspace"
requested_permissions:
defaults:
http_requests:
include: ["api.diaryx.org"]
reasons:
http_requests: "Connect to sync server"
---
# Sync Plugin
Full description in markdown body...
"#;
#[test]
fn parse_registry_md() {
let registry = MarketplaceRegistry::from_markdown(SAMPLE_REGISTRY_MD).unwrap();
assert_eq!(registry.schema_version, 2);
assert_eq!(registry.plugins.len(), 1);
assert_eq!(registry.plugins[0].id, "diaryx.sync");
assert_eq!(registry.plugins[0].name, "Sync");
assert_eq!(registry.plugins[0].version, "1.2.3");
assert_eq!(registry.plugins[0].author, "Diaryx Team");
assert_eq!(registry.plugins[0].artifact.size, 2048000);
assert!(registry.body.contains("Browse and install"));
}
#[test]
fn parse_plugin_workspace_readme() {
let meta = PluginWorkspaceMetadata::from_markdown(SAMPLE_PLUGIN_README).unwrap();
assert_eq!(meta.id, "diaryx.sync");
assert_eq!(meta.name, "Sync");
assert_eq!(meta.version, "1.2.3");
assert_eq!(meta.summary, "Realtime multi-device sync");
assert_eq!(meta.author.as_deref(), Some("Diaryx Team"));
assert_eq!(meta.categories, vec!["sync", "collaboration"]);
assert_eq!(meta.artifact.sha256, "abc123");
assert!(meta.ui.is_some());
assert!(meta.cli.is_some());
assert!(meta.requested_permissions.is_some());
assert!(meta.body.contains("Full description"));
}
#[test]
fn plugin_workspace_to_marketplace_entry() {
let meta = PluginWorkspaceMetadata::from_markdown(SAMPLE_PLUGIN_README).unwrap();
let entry = meta.to_marketplace_entry();
assert_eq!(entry.id, "diaryx.sync");
assert_eq!(entry.name, "Sync");
assert_eq!(entry.author, "Diaryx Team");
assert_eq!(entry.artifact.url, meta.artifact.url);
assert!(entry.description.contains("Full description"));
}
#[test]
fn reject_wrong_schema_version() {
let content = "---\nschema_version: 1\ngenerated_at: \"2026-01-01\"\nplugins: []\n---\n";
let err = MarketplaceRegistry::from_markdown(content).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("expected 2"), "got: {msg}");
}
#[test]
fn reject_missing_id() {
let content = r#"---
schema_version: 2
generated_at: "2026-01-01"
plugins:
- name: "Test"
version: "1.0.0"
summary: "Test"
description: "Test"
author: "Test"
license: "MIT"
artifact:
url: "https://example.com/test.wasm"
sha256: "abc"
size: 100
published_at: "2026-01-01"
---
"#;
let err = MarketplaceRegistry::from_markdown(content).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("missing") || msg.contains("id"), "got: {msg}");
}
#[test]
fn reject_missing_artifact_in_workspace() {
let content = "---\ntitle: Test\nid: test.plugin\nversion: \"1.0.0\"\n---\nBody\n";
let err = PluginWorkspaceMetadata::from_markdown(content).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("artifact"), "got: {msg}");
}
#[test]
fn roundtrip_marketplace_entry() {
let entry = MarketplaceEntry {
id: "test.plugin".to_string(),
name: "Test".to_string(),
version: "1.0.0".to_string(),
summary: "A test plugin".to_string(),
description: "Longer description".to_string(),
author: "Tester".to_string(),
license: "MIT".to_string(),
repository: Some("https://example.com".to_string()),
categories: vec!["test".to_string()],
tags: vec!["example".to_string()],
artifact: PluginArtifact {
url: "https://example.com/test.wasm".to_string(),
sha256: "abc123".to_string(),
size: 1024,
published_at: "2026-03-03T00:00:00Z".to_string(),
},
capabilities: vec!["custom".to_string()],
icon: None,
screenshots: vec![],
requested_permissions: None,
protocol_version: Some(1),
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: MarketplaceEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, deserialized);
}
#[test]
fn parse_inline_mark_editor_extension_contribution() {
let contribution: UiContribution = serde_json::from_value(serde_json::json!({
"slot": "EditorExtension",
"extension_id": "spoiler",
"node_type": "InlineMark",
"markdown": {
"level": "Inline",
"open": "||",
"close": "||"
},
"render_export": null,
"edit_mode": null,
"css": ".spoiler { color: transparent; }",
"keyboard_shortcut": "Mod-Shift-s",
"click_behavior": {
"ToggleClass": {
"hidden_class": "spoiler-hidden",
"revealed_class": "spoiler-revealed"
}
},
"insert_command": {
"label": "Spoiler",
"icon": "eye-off",
"description": "Hide text behind a spoiler"
}
}))
.unwrap();
match contribution {
UiContribution::EditorExtension {
extension_id,
node_type,
render_export,
edit_mode,
insert_command,
keyboard_shortcut,
click_behavior,
..
} => {
assert_eq!(extension_id, "spoiler");
assert!(matches!(node_type, EditorNodeType::InlineMark));
assert_eq!(render_export, None);
assert!(edit_mode.is_none());
assert_eq!(insert_command.unwrap().label, "Spoiler");
assert_eq!(keyboard_shortcut.as_deref(), Some("Mod-Shift-s"));
assert!(matches!(
click_behavior,
Some(ClickBehavior::ToggleClass { .. })
));
}
other => panic!("expected editor extension, got {other:?}"),
}
}
#[test]
fn parse_builtin_editor_node_type() {
let node_type: EditorNodeType = serde_json::from_value(serde_json::json!({
"Builtin": {
"host_extension_id": "templateVariable"
}
}))
.unwrap();
match node_type {
EditorNodeType::Builtin { host_extension_id } => {
assert_eq!(host_extension_id, "templateVariable");
}
other => panic!("expected builtin node type, got {other:?}"),
}
}
}