feat: implement the bDS2-compatible core Lua API

This commit is contained in:
2026-07-19 09:24:39 +02:00
parent 33929fd04b
commit 9a72287fc6
37 changed files with 11990 additions and 117 deletions

View File

@@ -34,6 +34,7 @@ diesel_migrations = { workspace = true }
libsqlite3-sys = { workspace = true }
mlua = { workspace = true }
url = { workspace = true }
base64 = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,12 @@
use std::fs;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let docs = root.join("docs/scripting");
let manifest = bds_core::scripting::api_manifest();
fs::write(docs.join("API_REFERENCE.md"), manifest.render_reference())?;
fs::write(docs.join("TYPES.md"), manifest.render_types())?;
fs::write(docs.join("completions.json"), manifest.render_completions())?;
Ok(())
}

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::sql_types::Text;
use crate::db::migrations;
@@ -17,6 +18,12 @@ pub enum DatabaseError {
/// Shared synchronous Diesel connection used by the engine query API.
pub struct DbConnection(RefCell<SqliteConnection>);
#[derive(QueryableByName)]
struct DatabasePathRow {
#[diesel(sql_type = Text)]
file: String,
}
impl DbConnection {
pub fn with<T>(
&self,
@@ -45,6 +52,20 @@ impl DbConnection {
.borrow_mut()
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
}
pub(crate) fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
self.with(|conn| {
diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'")
.get_result::<DatabasePathRow>(conn)
.and_then(|row| {
if row.file.is_empty() {
Err(diesel::result::Error::NotFound)
} else {
Ok(row.file.into())
}
})
})
}
}
/// Database wrapper managing a SQLite connection.
@@ -101,4 +122,16 @@ mod tests {
.unwrap();
assert_eq!(result, 1);
}
#[test]
fn reports_the_disk_database_path() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("project.sqlite3");
let db = Database::open(&path).unwrap();
assert_eq!(
db.conn().database_path().unwrap().canonicalize().unwrap(),
path.canonicalize().unwrap()
);
}
}

View File

@@ -1,5 +1,6 @@
use std::fs;
use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -9,7 +10,7 @@ use crate::db::DbConnection as Connection;
use crate::db::queries::script as script_queries;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, Script, ScriptKind};
use crate::scripting::{self, ExecutionControl, ExecutionKind};
use crate::scripting::{self, CoreHost, ExecutionControl, ExecutionKind, HostApi, UnavailableHost};
const MAX_TITLE_LENGTH: usize = 200;
const MAX_URL_LENGTH: usize = 2_048;
@@ -80,6 +81,27 @@ pub fn receive_deep_link(
data_dir: &Path,
project_id: &str,
raw: &str,
) -> EngineResult<BlogmarkImportResult> {
let host = CoreHost::from_connection(conn, project_id, data_dir)
.map(|host| Arc::new(host) as Arc<dyn HostApi>)
.unwrap_or_else(|_| Arc::new(UnavailableHost));
receive_deep_link_with_host(
conn,
data_dir,
project_id,
raw,
&ExecutionControl::default(),
host,
)
}
pub fn receive_deep_link_with_host(
conn: &Connection,
data_dir: &Path,
project_id: &str,
raw: &str,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> EngineResult<BlogmarkImportResult> {
let mut candidate = parse_deep_link(raw)?;
if let Some(target) = &candidate.project_id
@@ -98,7 +120,10 @@ pub fn receive_deep_link(
));
}
let (mut candidate, toasts, transform_errors) =
run_transforms(conn, data_dir, project_id, candidate)?;
run_transforms(conn, data_dir, project_id, candidate, control, host)?;
if control.is_cancelled() {
return Err(EngineError::Validation("script cancelled".into()));
}
if candidate.categories.is_empty() {
let metadata = crate::engine::meta::read_project_json(data_dir)?;
if let Some(category) = metadata
@@ -133,6 +158,8 @@ fn run_transforms(
data_dir: &Path,
project_id: &str,
candidate: BlogmarkCandidate,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> EngineResult<(BlogmarkCandidate, Vec<String>, Vec<String>)> {
let mut transforms = script_queries::list_scripts_by_project(conn, project_id)?
.into_iter()
@@ -149,6 +176,9 @@ fn run_transforms(
let mut toasts = Vec::new();
let mut errors = Vec::new();
for script in transforms {
if control.is_cancelled() {
return Err(EngineError::Validation("script cancelled".into()));
}
if script.entrypoint.trim().is_empty() {
continue;
}
@@ -157,12 +187,13 @@ fn run_transforms(
"source": "blogmark",
"url": current.get("url").cloned().unwrap_or(Value::Null),
});
match scripting::execute_many(
match scripting::execute_many_with_host(
&source,
&script.entrypoint,
&[current.clone(), context],
ExecutionKind::Transform,
&ExecutionControl::default(),
control,
Arc::clone(&host),
) {
Ok(execution) => {
let (next, returned_toasts) = split_transform_result(execution.value, &current);
@@ -172,6 +203,9 @@ fn run_transforms(
execution.toasts.into_iter().chain(returned_toasts),
);
}
Err(_error) if control.is_cancelled() => {
return Err(EngineError::Validation("script cancelled".into()));
}
Err(error) => errors.push(format!("{}: {error}", script.slug)),
}
}
@@ -287,6 +321,22 @@ mod tests {
use crate::model::ScriptKind;
use tempfile::TempDir;
struct PostsHost;
impl HostApi for PostsHost {
fn call(
&self,
namespace: &str,
method: &str,
_arguments: Vec<Value>,
) -> Result<Value, String> {
match (namespace, method) {
("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])),
_ => Err("unsupported test capability".into()),
}
}
}
#[test]
fn parses_and_hardens_blogmark_links() {
let candidate = parse_deep_link(
@@ -358,4 +408,80 @@ mod tests {
assert_eq!(result.toasts, vec!["done"]);
assert!(result.transform_errors.is_empty());
}
#[test]
fn transforms_use_the_supplied_project_host() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
let directory = TempDir::new().unwrap();
let project = crate::engine::project::create_project(
db.conn(),
"Blog",
Some(directory.path().to_str().unwrap()),
)
.unwrap();
crate::engine::script::create_script(
db.conn(),
&project.id,
"Count posts",
ScriptKind::Transform,
"function main(data) data.title = tostring(#bds.posts.get_all()) .. ' posts'; return data end",
None,
)
.unwrap();
let result = receive_deep_link_with_host(
db.conn(),
directory.path(),
&project.id,
"ruds://new-post?title=Example",
&ExecutionControl::default(),
Arc::new(PostsHost),
)
.unwrap();
assert_eq!(result.post.title, "2 posts");
}
#[test]
fn cancellation_stops_transform_import_before_post_creation() {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
let directory = TempDir::new().unwrap();
let project = crate::engine::project::create_project(
db.conn(),
"Blog",
Some(directory.path().to_str().unwrap()),
)
.unwrap();
crate::engine::script::create_script(
db.conn(),
&project.id,
"Cancelled",
ScriptKind::Transform,
"function main(data) return data end",
None,
)
.unwrap();
let control = ExecutionControl::default();
control.cancel();
assert!(
receive_deep_link_with_host(
db.conn(),
directory.path(),
&project.id,
"ruds://new-post?title=Example",
&control,
Arc::new(PostsHost),
)
.is_err()
);
assert_eq!(
crate::db::queries::post::count_posts_by_project(db.conn(), &project.id).unwrap(),
0
);
}
}

View File

@@ -93,6 +93,26 @@ pub fn rebuild_search_index(
}
}
/// Reindex one project without disturbing rows belonging to other projects.
pub fn reindex_project(
conn: &Connection,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<ReindexReport> {
let project = project_q::get_project_by_id(conn, project_id)?;
let total = post_q::count_posts_by_project(conn, project_id)? as usize
+ media_q::count_media_by_project(conn, project_id)? as usize;
let mut current = 0;
index_project(
conn,
project_id,
project.data_path.as_deref(),
total,
&mut current,
on_item.as_ref(),
)
}
fn index_all_projects(
conn: &Connection,
on_item: Option<&ItemProgressFn>,
@@ -112,71 +132,94 @@ fn index_all_projects(
};
for project in projects {
let main_language = project
.data_path
.as_deref()
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
.and_then(|metadata| metadata.main_language)
.unwrap_or_else(|| "en".to_string());
let indexed = index_project(
conn,
&project.id,
project.data_path.as_deref(),
total,
&mut current,
on_item,
)?;
report.posts_indexed += indexed.posts_indexed;
report.media_indexed += indexed.media_indexed;
}
for post in post_q::list_posts_by_project(conn, &project.id)? {
current += 1;
if let Some(callback) = on_item {
callback(current, total, &post.title);
}
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
let translation_data = translations
.iter()
.map(|translation| fts::PostTranslationFts {
title: translation.title.clone(),
excerpt: translation.excerpt.clone(),
content: translation.content.clone(),
language: translation.language.clone(),
})
.collect::<Vec<_>>();
fts::index_post(
conn,
&post.id,
&post.title,
post.excerpt.as_deref(),
post.content.as_deref(),
&post.tags,
&post.categories,
&translation_data,
post.language.as_deref().unwrap_or(&main_language),
)?;
report.posts_indexed += 1;
}
Ok(report)
}
for media in media_q::list_media_by_project(conn, &project.id)? {
current += 1;
if let Some(callback) = on_item {
callback(current, total, &media.original_name);
}
let translations =
media_translation::list_media_translations_by_media(conn, &media.id)?;
let translation_data = translations
.iter()
.map(|translation| fts::MediaTranslationFts {
title: translation.title.clone(),
alt: translation.alt.clone(),
caption: translation.caption.clone(),
language: translation.language.clone(),
})
.collect::<Vec<_>>();
fts::index_media(
conn,
&media.id,
media.title.as_deref(),
media.alt.as_deref(),
media.caption.as_deref(),
&media.original_name,
&media.tags,
&translation_data,
media.language.as_deref().unwrap_or(&main_language),
)?;
report.media_indexed += 1;
fn index_project(
conn: &Connection,
project_id: &str,
data_path: Option<&str>,
total: usize,
current: &mut usize,
on_item: Option<&ItemProgressFn>,
) -> EngineResult<ReindexReport> {
let main_language = data_path
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
.and_then(|metadata| metadata.main_language)
.unwrap_or_else(|| "en".to_string());
let mut report = ReindexReport {
posts_indexed: 0,
media_indexed: 0,
};
for post in post_q::list_posts_by_project(conn, project_id)? {
*current += 1;
if let Some(callback) = on_item {
callback(*current, total, &post.title);
}
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
let translation_data = translations
.iter()
.map(|translation| fts::PostTranslationFts {
title: translation.title.clone(),
excerpt: translation.excerpt.clone(),
content: translation.content.clone(),
language: translation.language.clone(),
})
.collect::<Vec<_>>();
fts::index_post(
conn,
&post.id,
&post.title,
post.excerpt.as_deref(),
post.content.as_deref(),
&post.tags,
&post.categories,
&translation_data,
post.language.as_deref().unwrap_or(&main_language),
)?;
report.posts_indexed += 1;
}
for media in media_q::list_media_by_project(conn, project_id)? {
*current += 1;
if let Some(callback) = on_item {
callback(*current, total, &media.original_name);
}
let translations = media_translation::list_media_translations_by_media(conn, &media.id)?;
let translation_data = translations
.iter()
.map(|translation| fts::MediaTranslationFts {
title: translation.title.clone(),
alt: translation.alt.clone(),
caption: translation.caption.clone(),
language: translation.language.clone(),
})
.collect::<Vec<_>>();
fts::index_media(
conn,
&media.id,
media.title.as_deref(),
media.alt.as_deref(),
media.caption.as_deref(),
&media.original_name,
&media.tags,
&translation_data,
media.language.as_deref().unwrap_or(&main_language),
)?;
report.media_indexed += 1;
}
Ok(report)
@@ -286,4 +329,40 @@ mod tests {
assert_eq!(report.posts_indexed, 2);
assert_eq!(results.len(), 2);
}
#[test]
fn project_reindex_leaves_other_project_rows_intact() {
let (db, first_project_id) = setup();
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
let second = engine::project::create_project(
db.conn(),
"Second Project",
Some(second_dir.path().to_str().unwrap()),
)
.unwrap();
for (project_id, data_dir, title) in [
(&first_project_id, first_dir.path(), "First Searchable"),
(&second.id, second_dir.path(), "Second Searchable"),
] {
engine::post::create_post(
db.conn(),
data_dir,
project_id,
title,
Some("body"),
vec![],
vec![],
None,
Some("en"),
None,
)
.unwrap();
}
reindex_project(db.conn(), &first_project_id, None).unwrap();
let results = crate::db::fts::search_posts(db.conn(), "searchable", "en").unwrap();
assert_eq!(results.len(), 2);
}
}

View File

@@ -179,6 +179,16 @@ impl TaskManager {
.unwrap_or(false)
}
/// Shared cancellation flag for a worker owned by this task.
pub fn cancellation_flag(&self, task_id: TaskId) -> Option<Arc<AtomicBool>> {
self.tasks
.lock()
.unwrap()
.iter()
.find(|task| task.id == task_id)
.map(|task| Arc::clone(&task.cancel_flag))
}
/// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap();
@@ -216,6 +226,14 @@ impl TaskManager {
});
}
/// Remove every finished task while preserving running and queued work.
pub fn clear_completed(&self) {
self.tasks
.lock()
.unwrap()
.retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running));
}
/// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap();
@@ -403,6 +421,17 @@ mod tests {
assert_eq!(mgr.snapshots().len(), 10);
}
#[test]
fn clear_completed_preserves_active_tasks() {
let mgr = TaskManager::new(2);
let done = mgr.submit("done");
let running = mgr.submit("running");
mgr.complete(done);
mgr.clear_completed();
assert_eq!(mgr.status(done), None);
assert_eq!(mgr.status(running), Some(TaskStatus::Running));
}
#[test]
fn completing_task_starts_next_queued() {
let mgr = TaskManager::new(1);

View File

@@ -135,6 +135,17 @@ pub struct PublishingPreferences {
pub ssh_mode: SshMode,
}
impl Default for PublishingPreferences {
fn default() -> Self {
Self {
ssh_host: None,
ssh_user: None,
ssh_remote_path: None,
ssh_mode: default_ssh_mode(),
}
}
}
fn default_ssh_mode() -> SshMode {
SshMode::Scp
}

View File

@@ -1,11 +1,23 @@
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use serde_json::{Map, Value as JsonValue};
#[derive(Debug, Clone, Default)]
#[derive(Clone)]
pub(crate) struct MacroRenderContext {
pub roots: Map<String, JsonValue>,
pub post_id: Option<String>,
pub host: Arc<dyn crate::scripting::HostApi>,
}
impl Default for MacroRenderContext {
fn default() -> Self {
Self {
roots: Map::new(),
post_id: None,
host: Arc::new(crate::scripting::UnavailableHost),
}
}
}
pub(crate) fn expand_builtin_macros(markdown: &str, context: &MacroRenderContext) -> String {
@@ -77,12 +89,13 @@ fn render_script_macro(
"translations": context.roots.get("translations").cloned().unwrap_or(JsonValue::Array(Vec::new())),
});
let params = serde_json::to_value(args).ok()?;
match crate::scripting::execute_many(
match crate::scripting::execute_many_with_host(
source,
entrypoint,
&[params, env],
crate::scripting::ExecutionKind::Macro,
&crate::scripting::ExecutionControl::default(),
Arc::clone(&context.host),
) {
Ok(result) => Some(match result.value {
JsonValue::Null => String::new(),
@@ -519,6 +532,7 @@ mod tests {
&MacroRenderContext {
roots,
post_id: Some("post-1".to_string()),
..MacroRenderContext::default()
},
);
@@ -555,6 +569,7 @@ mod tests {
&MacroRenderContext {
roots,
post_id: Some("post-1".into()),
..MacroRenderContext::default()
},
);
assert_eq!(rendered, "<aside>Hallo:de</aside>");

View File

@@ -11,6 +11,7 @@ pub use generation::{
write_generated_bytes, write_generated_file,
};
pub use markdown::render_markdown_to_html;
pub(crate) use page_renderer::render_liquid_template_with_host;
pub use page_renderer::{RenderError, render_liquid_template};
pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page,

View File

@@ -1,8 +1,11 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource};
use liquid_core::model::ScalarCow;
use liquid_core::parser::FilterArguments;
use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
ParseFilter, Runtime, Value, ValueView,
@@ -14,6 +17,7 @@ use thiserror::Error;
use crate::i18n::translate_render;
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
use crate::render::render_markdown_to_html;
use crate::scripting::{HostApi, UnavailableHost};
use crate::util::slugify;
#[derive(Debug, Error)]
@@ -32,6 +36,20 @@ pub fn render_liquid_template<T: Serialize>(
template_source: &str,
partials: &HashMap<String, String>,
context: &T,
) -> Result<String, RenderError> {
render_liquid_template_with_host(
template_source,
partials,
context,
Arc::new(UnavailableHost),
)
}
pub(crate) fn render_liquid_template_with_host<T: Serialize>(
template_source: &str,
partials: &HashMap<String, String>,
context: &T,
host: Arc<dyn HostApi>,
) -> Result<String, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials {
@@ -40,7 +58,7 @@ pub fn render_liquid_template<T: Serialize>(
let parser = ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown)
.filter(Markdown { host })
.filter(Slugify)
.partials(compiled_partials)
.build()?;
@@ -121,20 +139,43 @@ struct MarkdownArgs {
language_prefix: Option<Expression>,
}
#[derive(Clone, ParseFilter, FilterReflection)]
#[derive(Clone, FilterReflection)]
#[filter(
name = "markdown",
description = "Render markdown to HTML and rewrite preview URLs.",
parameters(MarkdownArgs),
parsed(MarkdownFilter)
)]
struct Markdown;
struct Markdown {
host: Arc<dyn HostApi>,
}
#[derive(Debug, FromFilterParameters, Display_filter)]
impl ParseFilter for Markdown {
fn parse(&self, args: FilterArguments<'_>) -> liquid_core::Result<Box<dyn Filter>> {
Ok(Box::new(MarkdownFilter {
args: MarkdownArgs::from_args(args)?,
host: Arc::clone(&self.host),
}))
}
fn reflection(&self) -> &dyn FilterReflection {
self
}
}
#[derive(Display_filter)]
#[name = "markdown"]
struct MarkdownFilter {
#[parameters]
args: MarkdownArgs,
host: Arc<dyn HostApi>,
}
impl fmt::Debug for MarkdownFilter {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MarkdownFilter")
.finish_non_exhaustive()
}
}
impl Filter for MarkdownFilter {
@@ -158,6 +199,7 @@ impl Filter for MarkdownFilter {
post_id: runtime
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())),
host: Arc::clone(&self.host),
};
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
@@ -473,8 +515,31 @@ fn trim_html_suffix(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls};
use super::{
RewriteContextView, render_liquid_template, render_liquid_template_with_host,
rewrite_rendered_html_urls,
};
use std::collections::HashMap;
use std::sync::Arc;
use crate::scripting::HostApi;
use serde_json::{Value, json};
struct PostsHost;
impl HostApi for PostsHost {
fn call(
&self,
namespace: &str,
method: &str,
_arguments: Vec<Value>,
) -> Result<Value, String> {
match (namespace, method) {
("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])),
_ => Err("unsupported test capability".into()),
}
}
}
struct TestRewriteContext {
canonical_post_path_by_slug: HashMap<String, String>,
@@ -520,4 +585,25 @@ mod tests {
assert_eq!(rendered, "ueber-die-bruecke");
}
#[test]
fn script_macros_use_the_supplied_project_host() {
let rendered = render_liquid_template_with_host(
"{{ post.content | markdown }}",
&HashMap::new(),
&json!({
"post": {"id": "post-1", "content": "[[count]]"},
"macro_scripts": {
"count": {
"source": "function render() return tostring(#bds.posts.get_all()) end",
"entrypoint": "render"
}
}
}),
Arc::new(PostsHost),
)
.unwrap();
assert_eq!(rendered, "<p>2</p>\n");
}
}

View File

@@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap};
use std::error::Error;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use crate::db::DbConnection as Connection;
use chrono::{Datelike, TimeZone, Utc};
@@ -16,8 +17,9 @@ use crate::model::{
};
use crate::render::{
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
render_liquid_template, resolve_post_template,
render_liquid_template_with_host, resolve_post_template,
};
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
use crate::util::slugify;
@@ -62,7 +64,7 @@ pub struct PreviewRenderResult {
pub html: String,
}
#[derive(Debug, Clone)]
#[derive(Clone)]
struct TemplateBundle {
post_templates: Vec<Template>,
template_source_by_slug: HashMap<String, String>,
@@ -71,6 +73,7 @@ struct TemplateBundle {
not_found_template: String,
partials: HashMap<String, String>,
macro_scripts: HashMap<String, Value>,
host: Arc<dyn HostApi>,
}
#[derive(Debug, Clone)]
@@ -268,6 +271,10 @@ fn load_template_bundle(
let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string();
let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string();
let mut macro_scripts = HashMap::new();
let host: Arc<dyn HostApi> = CoreHost::from_connection(conn, project_id, data_dir)
.map(|host| host.with_offline_mode(true))
.map(|host| Arc::new(host) as Arc<dyn HostApi>)
.unwrap_or_else(|_| Arc::new(UnavailableHost));
for script in queries::script::list_scripts_by_project(conn, project_id).unwrap_or_default() {
if script.kind != ScriptKind::Macro
@@ -368,6 +375,7 @@ fn load_template_bundle(
not_found_template,
partials,
macro_scripts,
host,
})
}
@@ -661,10 +669,11 @@ fn render_list_route(
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template(
Ok(render_liquid_template_with_host(
list_template,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
}
@@ -776,10 +785,11 @@ fn render_post_route(
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template(
Ok(render_liquid_template_with_host(
&template_source,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
}
@@ -824,10 +834,11 @@ fn render_not_found_route(
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(),
});
Ok(render_liquid_template(
Ok(render_liquid_template_with_host(
&bundle.not_found_template,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,430 @@
use std::collections::BTreeSet;
use std::sync::LazyLock;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct ApiManifest {
pub version: String,
pub root_methods: Vec<ApiMethod>,
pub methods: Vec<ApiMethod>,
pub types: Vec<ApiType>,
}
#[derive(Debug, Deserialize)]
pub struct ApiMethod {
#[serde(default = "bds2_compatibility")]
pub compatibility: String,
#[serde(default)]
pub namespace: String,
pub name: String,
pub params: Vec<ApiParameter>,
pub returns: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct ApiParameter {
pub name: String,
#[serde(rename = "type")]
pub type_name: String,
pub required: bool,
}
#[derive(Debug, Deserialize)]
pub struct ApiType {
pub name: String,
pub description: String,
pub fields: serde_json::Map<String, serde_json::Value>,
}
impl ApiManifest {
pub fn namespaces(&self) -> Vec<&str> {
self.methods
.iter()
.map(|method| method.namespace.as_str())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn render_reference(&self) -> String {
let mut output = format!(
"# RuDS Lua API Reference\n\nContract version: `{}`\n\nThe `bds` global is the supported bridge from sandboxed Lua scripts to RuDS. The unmarked method signatures are identical to bDS2 so the same published script file can run in either application. Calls are synchronous, JSON-compatible values cross the bridge as Lua tables, and project-scoped methods always use the active project.\n\n## Usage\n\nA utility script exposes `main(input)` and calls the API through `bds`:\n\n```lua\nfunction main(input)\n local posts = bds.posts.get_all()\n bds.app.log(\"Found \" .. #posts .. \" posts\")\n return posts\nend\n```\n\nMacro scripts expose `render(input, context)` and transform scripts expose `main(input, context)`. Complete runnable files are in [`examples/`](examples/). Scripts cannot access the network, filesystem, processes, environment variables, or native Lua modules directly; use the documented host methods instead. Host failures return `nil` or `false` where the signature permits it.\n\n## Conventions\n\n- A parameter ending in `?` is optional.\n- `T | nil` means the call can return no value. Check for `nil` before using it.\n- `T[]` is a one-based Lua array.\n- Public records are documented in [Lua API Types](TYPES.md).\n- Dates and timestamps returned by RuDS records are ISO-8601 strings.\n- Methods marked **RuDS extension** are not available to scripts running under bDS2.\n\n## Contents\n",
self.version
);
if !self.root_methods.is_empty() {
output.push_str("\n- [Root helpers](#root-helpers)");
}
for namespace in self.namespaces() {
output.push_str(&format!("\n- [`bds.{namespace}`](#bds{namespace})"));
}
output.push_str("\n- [Public data types](TYPES.md)\n");
if !self.root_methods.is_empty() {
output.push_str("\n## Root helpers\n");
for method in &self.root_methods {
self.render_method(&mut output, method);
}
}
for namespace in self.namespaces() {
output.push_str(&format!("\n## `bds.{namespace}`\n"));
for method in self
.methods
.iter()
.filter(|method| method.namespace == namespace)
{
self.render_method(&mut output, method);
}
}
output
}
pub fn render_types(&self) -> String {
let mut output = format!(
"# Lua API Types\n\nContract version: `{}`\n\nThese are the public, JSON-compatible records returned by the Lua host API. They contain no database handles or private implementation fields.\n\n## Value conventions\n\n- `T | nil` marks an optional field.\n- `T[]` is a one-based Lua array.\n- `table` is a JSON-compatible Lua table whose shape depends on the operation.\n- ISO-8601 values are strings such as `2026-07-19T08:00:00Z`.\n\n## Contents\n",
self.version
);
for api_type in &self.types {
output.push_str(&format!(
"\n- [`{}`](#{})",
api_type.name,
api_type.name.to_lowercase()
));
}
output.push('\n');
for api_type in &self.types {
output.push_str(&format!(
"\n## `{}`\n\n{}\n\n**Lua shape**\n\n```lua\n{}\n```\n\n| Field | Type | Required | Meaning |\n| --- | --- | --- | --- |\n",
api_type.name,
api_type.description,
self.example_for_type(api_type)
));
for (field, type_name) in &api_type.fields {
let type_name = type_name.as_str().unwrap_or("any");
output.push_str(&format!(
"| `{field}` | `{}` | {} | {} |\n",
type_name.replace('|', "\\|"),
if type_name.contains("nil") {
"No"
} else {
"Yes"
},
field_description(field)
));
}
}
output
}
pub fn render_completions(&self) -> String {
let completions = self
.methods
.iter()
.map(|method| {
serde_json::json!({
"label": format!("bds.{}.{}", method.namespace, method.name),
"insert_text": self.example_call(method),
"detail": self.signature(method),
"description": method.description,
"parameters": method.params.iter().map(|param| serde_json::json!({
"name": param.name,
"type": param.type_name,
"required": param.required,
})).collect::<Vec<_>>(),
"returns": method.returns,
})
})
.chain(self.root_methods.iter().map(|method| {
serde_json::json!({
"label": format!("bds.{}", method.name),
"insert_text": self.example_call(method),
"detail": self.signature(method),
"description": method.description,
"parameters": method.params.iter().map(|param| serde_json::json!({
"name": param.name,
"type": param.type_name,
"required": param.required,
})).collect::<Vec<_>>(),
"returns": method.returns,
})
}))
.collect::<Vec<_>>();
serde_json::to_string_pretty(&completions).expect("completion data is serializable") + "\n"
}
fn render_method(&self, output: &mut String, method: &ApiMethod) {
let path = method_path(method);
output.push_str(&format!("\n### `{path}`\n\n{}\n\n", method.description));
if method.compatibility == "ruds" {
output.push_str(
"> **RuDS extension:** this helper is not part of the portable bDS2 API.\n\n",
);
}
output.push_str(&format!(
"**Signature**\n\n```text\n{}\n```\n\n**Parameters**\n\n",
self.signature(method)
));
if method.params.is_empty() {
output.push_str("None.\n");
} else {
output.push_str("| Name | Type | Required | Example |\n| --- | --- | --- | --- |\n");
for param in &method.params {
output.push_str(&format!(
"| `{}` | `{}` | {} | `{}` |\n",
param.name,
param.type_name.replace('|', "\\|"),
if param.required { "Yes" } else { "No" },
example_argument(param).replace('|', "\\|")
));
}
}
output.push_str(&format!(
"\n**Returns**\n\n{}",
self.render_return_type(&method.returns)
));
if method.returns.contains("nil") {
output.push_str(" `nil` means no value was available or the host operation failed.");
} else if method.returns == "boolean" {
output.push_str(" `false` means the operation was rejected or failed.");
}
output.push_str(&format!(
"\n\n**Example call**\n\n```lua\nlocal result = {}\n```\n\n**Example response**\n\n```lua\n{}\n```\n",
self.example_call(method),
self.example_response(&method.returns)
));
}
fn signature(&self, method: &ApiMethod) -> String {
let params = method
.params
.iter()
.map(|param| {
format!(
"{}{}: {}",
param.name,
if param.required { "" } else { "?" },
param.type_name
)
})
.collect::<Vec<_>>()
.join(", ");
format!("{}({params}) -> {}", method_path(method), method.returns)
}
fn example_call(&self, method: &ApiMethod) -> String {
let arguments = method
.params
.iter()
.map(example_argument)
.collect::<Vec<_>>()
.join(", ");
format!("{}({arguments})", method_path(method))
}
fn render_return_type(&self, returns: &str) -> String {
let references = self
.types
.iter()
.filter(|api_type| returns.contains(&api_type.name))
.map(|api_type| {
format!(
"[`{}`](TYPES.md#{})",
api_type.name,
api_type.name.to_lowercase()
)
})
.collect::<Vec<_>>();
if references.is_empty() {
format!("`{returns}`.")
} else {
format!("`{returns}`. See {}.", references.join(", "))
}
}
fn example_response(&self, returns: &str) -> String {
self.example_response_named("result", returns)
}
fn example_response_named(&self, name: &str, returns: &str) -> String {
let base = returns.split('|').next().unwrap_or(returns).trim();
if let Some(element) = base.strip_suffix("[]") {
if let Some(api_type) = self.types.iter().find(|item| item.name == element) {
return format!("{{\n{}\n}}", indent(&self.example_for_type(api_type), 2));
}
return match element {
"string" => "{ \"example\" }".to_owned(),
_ => "{ { key = \"value\" } }".to_owned(),
};
}
if let Some(api_type) = self.types.iter().find(|item| item.name == base) {
return self.example_for_type(api_type);
}
example_value(name, base)
}
fn example_for_type(&self, api_type: &ApiType) -> String {
let fields = api_type
.fields
.iter()
.map(|(field, type_name)| {
format!(
" {field} = {},",
self.example_response_named(field, type_name.as_str().unwrap_or("any"))
)
})
.collect::<Vec<_>>()
.join("\n");
format!("{{\n{fields}\n}}")
}
}
fn method_path(method: &ApiMethod) -> String {
if method.namespace.is_empty() {
format!("bds.{}", method.name)
} else {
format!("bds.{}.{}", method.namespace, method.name)
}
}
fn bds2_compatibility() -> String {
"bds2".to_owned()
}
fn example_argument(param: &ApiParameter) -> String {
match param.type_name.as_str() {
"table" => match param.name.as_str() {
"credentials" => "{ host = \"example.com\", username = \"author\" }".to_owned(),
"filters" => "{ status = \"draft\" }".to_owned(),
"options" => "{ language = \"en\" }".to_owned(),
"prefs" => "{ publish_drafts = false }".to_owned(),
"source_tag_ids" => "{ \"tag-1\", \"tag-2\" }".to_owned(),
"updates" => "{ description = \"Updated description\" }".to_owned(),
"payload" => "{ current = 1, total = 10, message = \"Working\" }".to_owned(),
_ => "{ title = \"Example\" }".to_owned(),
},
"number" => match param.name.as_str() {
"total" => "100".to_owned(),
_ => "1".to_owned(),
},
"boolean" => "true".to_owned(),
type_name if type_name.contains("string") => example_string(&param.name),
_ => "nil".to_owned(),
}
}
fn example_value(name: &str, type_name: &str) -> String {
let base = type_name.split('|').next().unwrap_or(type_name).trim();
if base.ends_with("[]") {
return if base == "string[]" {
"{ \"example\" }".to_owned()
} else {
"{ { key = \"value\" } }".to_owned()
};
}
match base {
"nil" => "nil".to_owned(),
"boolean" => "true".to_owned(),
"integer" => "1".to_owned(),
"number" => "0.5".to_owned(),
"table" => "{ key = \"value\" }".to_owned(),
value if value.contains("ISO-8601") => "\"2026-07-19T08:00:00Z\"".to_owned(),
"string" => example_string(name),
_ => "{ key = \"value\" }".to_owned(),
}
}
fn example_string(name: &str) -> String {
let value = match name {
"action" => "new-post",
"alt" => "A descriptive alternative",
"caption" => "Example caption",
"color" => "#336699",
"content" | "text" => "Example content",
"data_path" | "folder_path" | "item_path" | "source_path" | "file_path" => "/path/to/item",
"default_author" => "Ada Author",
"entrypoint" => "main",
"kind" => "utility",
"language" | "main_language" => "en",
"message" => "Working",
"mime_type" => "image/png",
"name" | "new_name" => "Example",
"original_name" => "image.png",
"public_url" => "https://example.com",
"query" => "rust",
"size" => "small",
"slug" | "post_template_slug" => "example-post",
"status" => "draft",
"title" => "Example post",
value if value == "id" || value.ends_with("_id") => "example-id",
_ => "example",
};
format!("\"{value}\"")
}
fn field_description(field: &str) -> &'static str {
match field {
"id" => "Stable record identifier.",
"project_id" => "Identifier of the owning project.",
"name" => "Human-readable name.",
"title" => "Human-readable title.",
"slug" => "URL-safe record identifier.",
"description" => "Human-readable description.",
"status" => "Current lifecycle state.",
"progress" => "Completion value reported by the task.",
"message" => "Latest user-facing task message.",
"created_at" => "Creation timestamp.",
"updated_at" => "Last-update timestamp.",
"language" | "main_language" => "BCP 47 language code.",
"blog_languages" => "Languages configured for the blog.",
"categories" => "Assigned category names.",
"tags" => "Assigned tag names.",
"active_count" => "Number of active tasks.",
"running_count" => "Number of currently running tasks.",
"pending_count" => "Number of queued tasks.",
"tasks" => "Tasks included in this status snapshot.",
"errors" => "Validation error messages.",
"valid" => "Whether validation succeeded.",
"is_active" => "Whether this is the active project.",
"enabled" => "Whether the record is enabled.",
"data_path" => "Filesystem path containing project data.",
"public_url" => "Published site base URL.",
"default_author" => "Default post author name.",
"publishing_preferences" => "Project publishing configuration.",
"backlinks" => "Links from other posts to this post.",
"links_to" => "Links from this post to other posts.",
"original_name" => "Original imported filename.",
"mime_type" => "Media MIME type.",
"file_path" => "Stored media file path.",
"alt" => "Alternative text for the media.",
"caption" => "Media caption.",
"kind" => "Script or template kind.",
"entrypoint" => "Lua function invoked by the runtime.",
"color" => "Optional display color.",
"post_template_slug" => "Template selected for tagged posts.",
_ => "Public value returned by the host API.",
}
}
fn indent(value: &str, spaces: usize) -> String {
let padding = " ".repeat(spaces);
value
.lines()
.map(|line| format!("{padding}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
static API_MANIFEST: LazyLock<ApiManifest> = LazyLock::new(|| {
serde_json::from_str(include_str!("../../../../docs/scripting/api.json"))
.expect("docs/scripting/api.json must be valid")
});
pub fn api_manifest() -> &'static ApiManifest {
&API_MANIFEST
}

View File

@@ -7,6 +7,12 @@ use mlua::{
};
use serde_json::Value as JsonValue;
mod core_host;
mod manifest;
pub use core_host::{AppHostHandler, CoreHost};
pub use manifest::{ApiManifest, api_manifest};
const MACRO_TIMEOUT: Duration = Duration::from_secs(10);
const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
const MAX_TOASTS_PER_SCRIPT: usize = 5;
@@ -34,6 +40,29 @@ pub struct ScriptExecution {
pub toasts: Vec<String>,
}
/// Project/application operations explicitly made available to sandboxed Lua.
pub trait HostApi: Send + Sync {
fn call(
&self,
namespace: &str,
method: &str,
arguments: Vec<JsonValue>,
) -> Result<JsonValue, String>;
}
pub(crate) struct UnavailableHost;
impl HostApi for UnavailableHost {
fn call(
&self,
_namespace: &str,
_method: &str,
_arguments: Vec<JsonValue>,
) -> Result<JsonValue, String> {
Err("host capability is unavailable in this execution context".into())
}
}
#[derive(Clone)]
pub struct ExecutionControl {
cancelled: Arc<AtomicBool>,
@@ -48,9 +77,17 @@ impl Default for ExecutionControl {
}
impl ExecutionControl {
pub fn from_cancelled(cancelled: Arc<AtomicBool>) -> Self {
Self { cancelled }
}
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Acquire)
}
}
/// Parse Lua source with the same runtime used for execution.
@@ -71,12 +108,32 @@ pub fn execute(
kind: ExecutionKind,
control: &ExecutionControl,
) -> Result<ScriptExecution, String> {
execute_many(
execute_many_with_host(
source,
entrypoint,
std::slice::from_ref(args),
kind,
control,
Arc::new(UnavailableHost),
)
}
/// Execute with project-scoped host capabilities.
pub fn execute_with_host(
source: &str,
entrypoint: &str,
args: &JsonValue,
kind: ExecutionKind,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> Result<ScriptExecution, String> {
execute_many_with_host(
source,
entrypoint,
std::slice::from_ref(args),
kind,
control,
host,
)
}
@@ -87,6 +144,25 @@ pub fn execute_many(
args: &[JsonValue],
kind: ExecutionKind,
control: &ExecutionControl,
) -> Result<ScriptExecution, String> {
execute_many_with_host(
source,
entrypoint,
args,
kind,
control,
Arc::new(UnavailableHost),
)
}
/// Execute with positional arguments and project-scoped host capabilities.
pub fn execute_many_with_host(
source: &str,
entrypoint: &str,
args: &[JsonValue],
kind: ExecutionKind,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> Result<ScriptExecution, String> {
if entrypoint.trim().is_empty() {
return Err("script entrypoint is empty".into());
@@ -96,7 +172,7 @@ pub fn execute_many(
let output = Arc::new(Mutex::new(Vec::<String>::new()));
let progress = Arc::new(Mutex::new(Vec::<ScriptProgress>::new()));
let toasts = Arc::new(Mutex::new(Vec::<String>::new()));
install_host_api(&lua, &output, &progress, &toasts)?;
install_host_api(&lua, &output, &progress, &toasts, host)?;
let started = Instant::now();
let cancelled = Arc::clone(&control.cancelled);
@@ -175,6 +251,7 @@ fn install_host_api(
output: &Arc<Mutex<Vec<String>>>,
progress: &Arc<Mutex<Vec<ScriptProgress>>>,
toasts: &Arc<Mutex<Vec<String>>>,
host: Arc<dyn HostApi>,
) -> Result<(), String> {
let globals = lua.globals();
@@ -208,13 +285,14 @@ fn install_host_api(
.collect::<Vec<_>>()
.join("\t");
log_output.lock().unwrap().push(line);
Ok(())
Ok(true)
})
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let progress_sink = Arc::clone(progress);
let progress_host = Arc::clone(&host);
app.set(
"progress",
lua.create_function(
@@ -222,9 +300,18 @@ fn install_host_api(
progress_sink.lock().unwrap().push(ScriptProgress {
current,
total,
message,
message: message.clone(),
});
Ok(())
let _ = progress_host.call(
"bds",
"report_progress",
vec![serde_json::json!({
"current": current,
"total": total,
"message": message,
})],
);
Ok(true)
},
)
.map_err(|error| error.to_string())?,
@@ -239,17 +326,97 @@ fn install_host_api(
if sink.len() < MAX_TOASTS_PER_SCRIPT {
sink.push(message.chars().take(MAX_TOAST_LENGTH).collect());
}
Ok(())
Ok(true)
})
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
bds.set("app", app).map_err(|error| error.to_string())?;
for namespace in api_manifest().namespaces() {
let table = if namespace == "app" {
bds.get(namespace).map_err(|error| error.to_string())?
} else {
let table = lua.create_table().map_err(|error| error.to_string())?;
bds.set(namespace, table.clone())
.map_err(|error| error.to_string())?;
table
};
for method in api_manifest()
.methods
.iter()
.filter(|method| method.namespace == namespace)
{
if namespace == "app" && matches!(method.name.as_str(), "log" | "progress" | "toast") {
continue;
}
let host = Arc::clone(&host);
let namespace = method.namespace.clone();
let method_name = method.name.clone();
let returns = method.returns.clone();
table
.set(
method.name.as_str(),
lua.create_function(move |lua, values: MultiValue| {
let arguments = values
.into_iter()
.map(|value| lua.from_value(value))
.collect::<mlua::Result<Vec<JsonValue>>>()?;
let value = host
.call(&namespace, &method_name, arguments)
.unwrap_or_else(|_| failure_value(&returns));
lua.to_value(&value)
})
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
}
}
let report_sink = Arc::clone(progress);
let report_host = Arc::clone(&host);
bds.set(
"report_progress",
lua.create_function(move |lua, payload: Value| {
let payload: JsonValue = lua.from_value(payload)?;
let current = payload
.get("current")
.or_else(|| payload.get("progress"))
.and_then(JsonValue::as_f64)
.unwrap_or_default();
let total = payload.get("total").and_then(JsonValue::as_f64);
let message = payload
.get("message")
.and_then(JsonValue::as_str)
.map(str::to_owned);
report_sink.lock().unwrap().push(ScriptProgress {
current,
total,
message,
});
let _ = report_host.call("bds", "report_progress", vec![payload]);
Ok(true)
})
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
globals.set("bds", bds).map_err(|error| error.to_string())?;
Ok(())
}
fn failure_value(returns: &str) -> JsonValue {
if returns == "boolean" {
JsonValue::Bool(false)
} else if returns.contains("[]") {
JsonValue::Array(Vec::new())
} else if returns == "table" {
JsonValue::Object(Default::default())
} else {
JsonValue::Null
}
}
fn lua_value_text(value: &Value) -> String {
match value {
Value::Nil => "nil".into(),
@@ -265,6 +432,292 @@ fn lua_value_text(value: &Value) -> String {
mod tests {
use super::*;
use serde_json::json;
use std::sync::Mutex;
struct RecordingHost(Mutex<Vec<String>>);
impl HostApi for RecordingHost {
fn call(
&self,
namespace: &str,
method: &str,
_arguments: Vec<JsonValue>,
) -> Result<JsonValue, String> {
self.0.lock().unwrap().push(format!("{namespace}.{method}"));
Ok(json!(format!("{namespace}.{method}")))
}
}
#[test]
fn manifest_and_runtime_expose_exact_core_namespaces() {
let manifest = api_manifest();
assert_eq!(
manifest.namespaces(),
[
"app",
"chat",
"media",
"meta",
"posts",
"projects",
"publish",
"scripts",
"tags",
"tasks",
"templates",
]
);
let execution = execute(
r#"
function main()
return {
sync = bds.sync,
embeddings = bds.embeddings,
report_progress = type(bds.report_progress),
post_search = type(bds.posts.search),
app_toast = type(bds.app.toast),
}
end
"#,
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
)
.unwrap();
assert_eq!(
execution.value,
json!({
"report_progress": "function",
"post_search": "function",
"app_toast": "function",
})
);
}
#[test]
fn representative_calls_from_every_namespace_reach_one_host_dispatcher() {
let host = Arc::new(RecordingHost(Mutex::new(Vec::new())));
let execution = execute_with_host(
r#"
function main()
return {
bds.app.get_data_paths(),
bds.projects.get_active(),
bds.meta.get_project_metadata(),
bds.posts.get_all(),
bds.media.get_all(),
bds.scripts.get_all(),
bds.templates.get_all(),
bds.tags.get_all(),
bds.tasks.status_snapshot(),
bds.publish.upload_site({}),
bds.chat.detect_post_language("title", "body"),
}
end
"#,
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
host.clone(),
)
.unwrap();
assert_eq!(execution.value.as_array().unwrap().len(), 11);
assert_eq!(host.0.lock().unwrap().len(), 11);
}
#[test]
fn spec_manifest_runtime_and_generated_docs_stay_in_sync() {
let manifest = api_manifest();
let spec = include_str!("../../../../specs/script.allium");
for namespace in manifest.namespaces() {
let marker = format!("bds.{namespace}:");
let start = spec
.find(&marker)
.unwrap_or_else(|| panic!("missing {marker} in spec"));
let list = &spec[start + marker.len()..];
let list = &list[..list.find('.').expect("method list ends with a period")];
let expected = list
.replace("--", "")
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_owned)
.collect::<std::collections::BTreeSet<_>>();
let actual = manifest
.methods
.iter()
.filter(|method| method.namespace == namespace && method.compatibility == "bds2")
.map(|method| method.name.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
actual, expected,
"bds.{namespace} differs from the Allium contract"
);
}
let checks = manifest
.methods
.iter()
.map(|method| {
format!(
"assert(type(bds.{}.{}) == 'function')",
method.namespace, method.name
)
})
.chain(
manifest
.root_methods
.iter()
.map(|method| format!("assert(type(bds.{}) == 'function')", method.name)),
)
.collect::<Vec<_>>()
.join("\n");
validate(&checks).unwrap();
execute(
&format!("{checks}\nfunction main() return true end"),
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
)
.unwrap();
assert_eq!(
manifest.render_reference(),
include_str!("../../../../docs/scripting/API_REFERENCE.md")
);
assert_eq!(
manifest.render_types(),
include_str!("../../../../docs/scripting/TYPES.md")
);
assert_eq!(
manifest.render_completions(),
include_str!("../../../../docs/scripting/completions.json")
);
}
#[test]
fn generated_reference_documents_every_manifest_entry() {
let manifest = api_manifest();
let undocumented = manifest
.methods
.iter()
.filter(|method| method.description.trim().is_empty())
.map(|method| format!("bds.{}.{}", method.namespace, method.name))
.collect::<Vec<_>>();
assert!(
undocumented.is_empty(),
"methods without documentation: {}",
undocumented.join(", ")
);
let undocumented_types = manifest
.types
.iter()
.filter(|api_type| api_type.description.trim().is_empty())
.map(|api_type| api_type.name.as_str())
.collect::<Vec<_>>();
assert!(
undocumented_types.is_empty(),
"types without documentation: {}",
undocumented_types.join(", ")
);
let reference = manifest.render_reference();
for section in [
"## Usage",
"**Parameters**",
"**Returns**",
"**Example call**",
"**Example response**",
] {
assert!(reference.contains(section), "missing {section}");
}
let types = manifest.render_types();
for section in ["## Value conventions", "**Lua shape**", "| Field | Type |"] {
assert!(types.contains(section), "missing {section}");
}
}
#[test]
fn portable_method_signatures_are_identical_to_bds2() {
let manifest = api_manifest();
let baseline: JsonValue = serde_json::from_str(include_str!(
"../../../../docs/scripting/bds2-core-signatures.json"
))
.unwrap();
assert_eq!(baseline["version"], manifest.version);
let expected = baseline["methods"]
.as_array()
.unwrap()
.iter()
.map(|method| {
let path = format!(
"{}.{}",
method["namespace"].as_str().unwrap(),
method["name"].as_str().unwrap()
);
(
path,
json!({"params": method["params"], "returns": method["returns"]}),
)
})
.collect::<std::collections::BTreeMap<_, _>>();
let actual = manifest
.methods
.iter()
.filter(|method| method.compatibility == "bds2")
.map(|method| {
let path = format!("{}.{}", method.namespace, method.name);
let params = method
.params
.iter()
.map(|param| {
json!({
"name": param.name,
"type": param.type_name,
"required": param.required,
})
})
.collect::<Vec<_>>();
(path, json!({"params": params, "returns": method.returns}))
})
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(actual, expected);
}
#[test]
fn bundled_examples_execute() {
execute_many(
include_str!("../../../../docs/scripting/examples/macro.lua"),
"render",
&[json!({"text":"Hello"}), json!({"mainLanguage":"en"})],
ExecutionKind::Macro,
&ExecutionControl::default(),
)
.unwrap();
execute_many(
include_str!("../../../../docs/scripting/examples/transform.lua"),
"main",
&[json!({}), json!({"source":"blogmark"})],
ExecutionKind::Transform,
&ExecutionControl::default(),
)
.unwrap();
execute_with_host(
include_str!("../../../../docs/scripting/examples/utility.lua"),
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
Arc::new(RecordingHost(Mutex::new(Vec::new()))),
)
.unwrap();
}
#[test]
fn executes_entrypoint_and_routes_output_and_progress() {
@@ -290,6 +743,23 @@ mod tests {
assert_eq!(result.progress[0].message.as_deref(), Some("half"));
}
#[test]
fn app_progress_reaches_the_live_host_once() {
let host = Arc::new(RecordingHost(Mutex::new(Vec::new())));
let result = execute_with_host(
"function main() bds.app.progress(1, 2, 'half') end",
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
Arc::clone(&host) as Arc<dyn HostApi>,
)
.unwrap();
assert_eq!(result.progress.len(), 1);
assert_eq!(host.0.lock().unwrap().as_slice(), &["bds.report_progress"]);
}
#[test]
fn sandbox_hides_ambient_host_capabilities() {
let result = execute(