1#![doc = include_str!("../README.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc)]
4
5pub mod content;
10pub mod project;
11
12use anyhow::bail;
13use heck::ToTitleCase;
14use ordinary_config::{
15 ActionConfig, ActionFfi, ActionFfiSerialization, ActionFfiVersion, ErrorConfig,
16 IntegrationConfig, IntegrationProtocol, ModelConfig, OrdinaryConfig, TemplateConfig,
17 TemplateFfi, TemplateFfiSerialization, TemplateFfiVersion, TemplateRef, UuidVersion,
18};
19use std::{io::Write, path::Path};
20use tracing::instrument;
21
22#[instrument(err)]
23#[allow(
24 clippy::too_many_arguments,
25 clippy::used_underscore_binding,
26 clippy::too_many_lines
27)]
28pub fn add_action(
29 path: &str,
30 name: &str,
31 lang: &str,
32 _access: (),
33 _accepts: (),
34 _returns: (),
35 transactional: &Option<bool>,
36 _protected: (),
37) -> anyhow::Result<()> {
38 let proj_path = Path::new(path);
39 let mut app_config = OrdinaryConfig::get(path)?;
40
41 let mut actions = app_config.actions.unwrap_or_default();
42
43 if actions.len() <= 225 {
44 let next_idx = actions.len();
45
46 let language = match lang {
47 "Rust" | "rust" | "rs" => ordinary_config::ActionLang::Rust,
48 "JavaScript" | "javascript" | "JS" | "js" => ordinary_config::ActionLang::JavaScript,
49 _ => bail!("language {lang} not yet supported"),
50 };
51
52 let action_dir_path = proj_path.join("actions").join(name);
53 fs_err::create_dir_all(&action_dir_path)?;
54
55 match language {
56 ordinary_config::ActionLang::Rust => {
57 let gitignore = "/target";
58
59 let cargo_toml = format!(
60 r#"[workspace]
61
62[package]
63name = "action"
64version = "0.1.0"
65edition = "2024"
66
67[dependencies]
68ordinary = {{ path = "../../.ordinary/gen/actions/{name}" }}
69
70[profile.release]
71strip = "symbols"
72lto = "fat"
73opt-level = "z"
74codegen-units = 1
75panic = "abort"
76"#
77 );
78
79 let main_rs = r#"use std::error::Error;
80use ordinary::{recv_in, send_out};
81
82fn main() -> Result<(), Box<dyn Error>> {
83 let _input = recv_in()?;
84
85 ordinary::trace(2, "Hello, Ordinary!")?;
86
87 send_out(())
88}
89"#;
90
91 let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
92 gitignore_file.write_all(gitignore.as_bytes())?;
93
94 let mut cargo_toml_file = fs_err::File::create(action_dir_path.join("Cargo.toml"))?;
95 cargo_toml_file.write_all(cargo_toml.as_bytes())?;
96
97 fs_err::create_dir_all(action_dir_path.join("src"))?;
98 let mut main_rs_file =
99 fs_err::File::create(action_dir_path.join("src").join("main.rs"))?;
100 main_rs_file.write_all(main_rs.as_bytes())?;
101 }
102 ordinary_config::ActionLang::JavaScript => {
103 let gitignore = r"/target
104node_modules";
105
106 let package_json = r#"{
107 "name": "action",
108 "version": "0.1.0",
109 "main": "index.js",
110 "files": [
111 "index.js",
112 "index.d.ts"
113 ],
114 "types": "client.d.ts",
115 "scripts": {
116 "build": "esbuild main.js --bundle --minify --outfile=index.js --target=es2020 --format=iife --global-name=action"
117 },
118 "devDependencies": {
119 "esbuild": "0.25.10"
120 }
121}
122 "#;
123
124 let main_js = r#"export function main(input) {
125 console.log("Hello, Ordinary!");
126}"#;
127
128 let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
129 gitignore_file.write_all(gitignore.as_bytes())?;
130
131 let mut package_json_file =
132 fs_err::File::create(action_dir_path.join("package.json"))?;
133 package_json_file.write_all(package_json.as_bytes())?;
134
135 let mut main_js_file = fs_err::File::create(action_dir_path.join("main.js"))?;
136 main_js_file.write_all(main_js.as_bytes())?;
137 }
138 }
139
140 actions.push(ActionConfig {
141 ffi: ActionFfi {
142 version: ActionFfiVersion::V1,
143 serialization: ActionFfiSerialization::FlexBufferVector,
144 },
145 idx: u8::try_from(next_idx)?,
146 dir_path: Some(format!("./actions/{name}")),
147 name: name.to_string(),
148 lang: language,
149 access: vec![],
150 accepts: ordinary_types::Kind::Void,
151 returns: ordinary_types::Kind::Void,
152 triggered_by: vec![],
153 transactional: *transactional,
154 protected: None,
155 wasm_opt: None,
156 timeout: None,
157 cors: None,
158 privileged: None,
159 variables: None,
160 middlewares: None,
161 });
162
163 app_config.actions = Some(actions);
164
165 let ordinary_json = serde_json::to_string_pretty(&app_config)?;
166
167 let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
168 file.write_all(ordinary_json.as_bytes())?;
169 } else {
170 tracing::error!("cannot support more than 255 actions for a single project.");
171 }
172
173 Ok(())
174}
175
176#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
177#[instrument(skip_all, err)]
178pub fn add_template(
179 path: &str,
180 name: &str,
181 route: &str,
182 mime: &str,
183 head_block: &str,
184 header_block: &str,
185 footer_block: &str,
186 is_error: bool,
187 globals: Option<Vec<String>>,
188 content_refs: Option<Vec<TemplateRef>>,
189) -> anyhow::Result<()> {
190 let proj_path = Path::new(path);
191 let mut app_config = OrdinaryConfig::get(path)?;
192
193 let mut templates = app_config.templates.unwrap_or_default();
194
195 if templates.len() <= 255 {
196 let next_idx = templates.len();
197
198 let cache = None;
199
200 let file_ext = match mime {
201 "text/html" | "text/html; charset=utf-8" => "html",
202 "text/xml" | "application/rss+xml" => "xml",
203 "text/plain" | "text/plain; charset=utf-8" => "txt",
204 _ => "",
205 };
206
207 fs_err::create_dir_all(proj_path.join("templates"))?;
208
209 let file_name = format!("{name}.{file_ext}");
210 let mut file = fs_err::File::create(proj_path.join("templates").join(&file_name))?;
211
212 let header_block = if header_block.is_empty() {
213 r#"<header>
214 <a href="/">{{ canonical }}</a>
215 </header>"#
216 } else {
217 header_block
218 };
219
220 let footer_block = if footer_block.is_empty() {
221 r#"<footer>
222 © 2026 Your Name | powered by <a href="https://codeberg.org/ordinarylabs/Ordinary">Ordinary</a>
223 </footer>"#
224 } else {
225 footer_block
226 };
227
228 if mime == "text/html" {
230 let main_block = if is_error {
231 "<h1>Status Code {{ error.code }}</h1>
232 <p>{{ error.message }}</p>"
233 .to_string()
234 } else {
235 format!("<h1>{}</h1>", name.to_title_case())
236 };
237
238 let default_html = format!(
239 r#"<!DOCTYPE html>
240 <html lang="en">
241
242 <head>
243 <meta charset="UTF-8">
244 <meta name="viewport" content="width=device-width, initial-scale=1.0">
245
246 <title>{name} | {{{{ canonical }}}}</title>
247 <meta name="generator" content="{{{{ generator }}}}">
248 {head_block}
249 </head>
250
251 <body>
252 {header_block}
253 <main>
254 {main_block}
255 </main>
256 {footer_block}
257 </body>
258 "#,
259 );
260 file.write_all(default_html.as_bytes())?;
261 file.flush()?;
262 } else {
263 file.write_all(&[][..])?;
264 file.flush()?;
265 }
266
267 templates.push(TemplateConfig {
268 ffi: TemplateFfi {
269 version: TemplateFfiVersion::V1,
270 serialization: TemplateFfiSerialization::FlexBufferVector,
271 },
272 idx: u8::try_from(next_idx)?,
273 name: name.to_string(),
274 route: route.to_string(),
276 mime: mime.to_string(),
278 cache,
279 path: Some(format!("./templates/{file_name}")),
280 globals,
281 content: content_refs,
282 ..Default::default()
283 });
284
285 app_config.templates = Some(templates);
286 if is_error {
287 app_config.error = Some(ErrorConfig {
288 template: Some(name.to_string()),
289 asset: None,
290 });
291 }
292
293 let ordinary_json = serde_json::to_string_pretty(&app_config)?;
294
295 let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
296 file.write_all(ordinary_json.as_bytes())?;
297 } else {
298 tracing::error!("cannot support more than 255 templates for a single project.");
299 }
300
301 Ok(())
302}
303
304#[instrument(err)]
305pub fn add_integration(
306 path: &str,
307 name: &str,
308 endpoint: &str,
309 protocol: &str,
310) -> anyhow::Result<()> {
311 let proj_path = Path::new(path);
312 let mut app_config = OrdinaryConfig::get(path)?;
313
314 let mut integrations = app_config.integrations.unwrap_or_default();
315
316 if integrations.len() <= 255 {
317 let next_idx = integrations.len();
318
319 integrations.push(IntegrationConfig {
320 idx: u8::try_from(next_idx)?,
321 name: name.to_string(),
322 protocol: match protocol {
323 "JSON" => IntegrationProtocol::Http {
324 method: "GET".to_string(),
325 headers: vec![],
326 send_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
327 recv_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
328 },
329 _ => bail!("invalid protocol"),
330 },
331 endpoint: endpoint.to_string(),
332 send: ordinary_types::Kind::Json,
333 recv: ordinary_types::Kind::Json,
334 secrets: None,
335 timeout: None,
336 });
337
338 app_config.integrations = Some(integrations);
339
340 let ordinary_json = serde_json::to_string_pretty(&app_config)?;
341
342 let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
343 file.write_all(ordinary_json.as_bytes())?;
344 } else {
345 tracing::error!("cannot support more than 255 integrations for a single project.");
346 }
347
348 Ok(())
349}
350
351#[instrument(err)]
352pub fn add_model(path: &str, name: &str, uuid: Option<&str>) -> anyhow::Result<()> {
353 let proj_path = Path::new(path);
354 let mut app_config = OrdinaryConfig::get(path)?;
355
356 let mut models = app_config.models.unwrap_or_default();
357
358 if models.len() <= 255 {
359 let next_idx = models.len();
360
361 models.push(ModelConfig {
362 idx: u8::try_from(next_idx)?,
363 name: name.to_string(),
364 fields: vec![],
365 uuid: match uuid {
366 Some(uuid) => match uuid {
367 "v4" => Some(UuidVersion::V4),
368 "v7" => Some(UuidVersion::V7),
369 _ => bail!("invalid UUID version"),
370 },
371 None => None,
372 },
373 });
374
375 app_config.models = Some(models);
376
377 let ordinary_json = serde_json::to_string_pretty(&app_config)?;
378
379 let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
380 file.write_all(ordinary_json.as_bytes())?;
381 } else {
382 tracing::error!("cannot support more than 255 models for a single project.");
383 }
384
385 Ok(())
386}