Skip to main content

ordinary_modify/
project.rs

1// Copyright (C) 2026 Ordinary Labs, LLC.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4
5use crate::add_template;
6use crate::content::add_obj;
7use anyhow::bail;
8use fs_err::File;
9use ordinary_config::TemplateRefFieldBind::Segment;
10use ordinary_config::{
11    AssetsConfig, Content, ContentDefinition, ContentObjectLifecycle, FragmentsConfig, Global,
12    LifecycleBeforeAfterScripts, OrdinaryConfig, TemplateConfig, TemplateFfi,
13    TemplateFfiSerialization, TemplateFfiVersion, TemplateRef, TemplateRefField, TopLevelLifecycle,
14};
15use ordinary_types::{ContentField, ContentObject, Field, Kind, TimeUnit};
16use serde_json::Value;
17use std::io::Write;
18use std::path::Path;
19use std::time::SystemTime;
20use tracing::instrument;
21
22const GITIGNORE: &[u8] = include_bytes!(concat!(
23    env!("CARGO_MANIFEST_DIR"),
24    "/static/project/.gitignore"
25));
26
27const README: &[u8] = include_bytes!(concat!(
28    env!("CARGO_MANIFEST_DIR"),
29    "/static/project/README.md"
30));
31
32const HOOKS_POSTS_SRC_MAIN_RS: &[u8] = include_bytes!(concat!(
33    env!("CARGO_MANIFEST_DIR"),
34    "/static/project/hooks/posts/src/main.rs"
35));
36
37const HOOKS_POSTS_CARGO_TOML: &[u8] = include_bytes!(concat!(
38    env!("CARGO_MANIFEST_DIR"),
39    "/static/project/hooks/posts/Freight.toml"
40));
41
42const TMP_CSS: &[u8] = include_bytes!(concat!(
43    env!("CARGO_MANIFEST_DIR"),
44    "/static/project/assets/themes/tmp.css"
45));
46
47const FAVICON: &[u8] = include_bytes!(concat!(
48    env!("CARGO_MANIFEST_DIR"),
49    "/static/project/assets/favicon.svg"
50));
51
52const INDEX: &[u8] = include_bytes!(concat!(
53    env!("CARGO_MANIFEST_DIR"),
54    "/static/project/templates/index.html"
55));
56
57const POST: &[u8] = include_bytes!(concat!(
58    env!("CARGO_MANIFEST_DIR"),
59    "/static/project/templates/post.html"
60));
61
62const SITEMAP: &[u8] = include_bytes!(concat!(
63    env!("CARGO_MANIFEST_DIR"),
64    "/static/project/templates/sitemap.xml"
65));
66
67const ROBOTS: &[u8] = include_bytes!(concat!(
68    env!("CARGO_MANIFEST_DIR"),
69    "/static/project/templates/robots.txt"
70));
71
72const RSS: &[u8] = include_bytes!(concat!(
73    env!("CARGO_MANIFEST_DIR"),
74    "/static/project/templates/rss.xml"
75));
76
77const HEADER: &[u8] = include_bytes!(concat!(
78    env!("CARGO_MANIFEST_DIR"),
79    "/static/project/fragments/header.html"
80));
81
82const FOOTER: &[u8] = include_bytes!(concat!(
83    env!("CARGO_MANIFEST_DIR"),
84    "/static/project/fragments/footer.html"
85));
86
87#[allow(clippy::too_many_lines)]
88#[instrument(skip_all, err)]
89pub fn new(path: &str, domain: &str) -> anyhow::Result<()> {
90    let path = Path::new(path).join(domain);
91
92    if path.exists() {
93        bail!("project already exists");
94    }
95
96    tracing::info!("creating project at {:?}", path);
97    fs_err::create_dir_all(&path)?;
98
99    let hooks_posts_path = path.join("hooks").join("posts");
100    fs_err::create_dir_all(hooks_posts_path.join("src"))?;
101
102    let mut file = File::create(hooks_posts_path.join("src").join("main.rs"))?;
103    file.write_all(HOOKS_POSTS_SRC_MAIN_RS)?;
104    file.flush()?;
105
106    let mut file = File::create(hooks_posts_path.join("Cargo.toml"))?;
107    file.write_all(HOOKS_POSTS_CARGO_TOML)?;
108    file.flush()?;
109
110    let app_config = OrdinaryConfig {
111        lifecycle: Some(TopLevelLifecycle {
112            before_all: Some(vec![
113                [
114                    "cargo",
115                    "build",
116                    "--release",
117                    "--manifest-path",
118                    "hooks/posts/Cargo.toml",
119                ]
120                .iter()
121                .map(ToString::to_string)
122                .collect::<Vec<_>>(),
123            ]),
124            build: Some(LifecycleBeforeAfterScripts {
125                before: Some(vec![
126                    [
127                        "hooks/posts/target/release/posts",
128                        "before_build",
129                        "content/posts",
130                    ]
131                    .iter()
132                    .map(ToString::to_string)
133                    .collect::<Vec<_>>(),
134                ]),
135                after: None,
136            }),
137        }),
138        domain: domain.to_string(),
139
140        version: "0.1.0".into(),
141        contacts: Some(vec![]),
142
143        storage_size: Some(5_000_000),
144
145        port: Some(4433),
146        redirect_port: Some(8080),
147        globals: Some(vec![Global {
148            name: "author".to_string(),
149            kind: Kind::String,
150            value: "Your Name".into(),
151        }]),
152
153        fragments: Some(FragmentsConfig {
154            dir_path: "fragments".to_string(),
155        }),
156        error: None,
157        assets: Some(AssetsConfig {
158            dir_path: Some("assets".to_string()),
159            ..Default::default()
160        }),
161
162        content: Some(Content {
163            file_path: "./content.json".to_string(),
164            definitions: vec![ContentDefinition {
165                idx: 0,
166                name: "post".to_string(),
167                fields: vec![
168                    Field {
169                        idx: 0,
170                        name: "slug".to_string(),
171                        kind: Kind::String,
172                        indexed: Some(true),
173                        ..Default::default()
174                    },
175                    Field {
176                        idx: 1,
177                        name: "title".to_string(),
178                        kind: Kind::String,
179                        ..Default::default()
180                    },
181                    Field {
182                        idx: 2,
183                        name: "date".to_string(),
184                        kind: Kind::Timestamp {
185                            unit: TimeUnit::Seconds,
186                        },
187                        ..Default::default()
188                    },
189                    Field {
190                        idx: 3,
191                        name: "body".to_string(),
192                        kind: Kind::Markdown,
193                        ..Default::default()
194                    },
195                ],
196                lifecycle: Some(ContentObjectLifecycle {
197                    before_all: None,
198                    on_add: Some(LifecycleBeforeAfterScripts {
199                        after: Some(vec![
200                            [
201                                "hooks/posts/target/release/posts",
202                                "after_add",
203                                "content/posts",
204                            ]
205                            .iter()
206                            .map(ToString::to_string)
207                            .collect::<Vec<_>>(),
208                        ]),
209                        before: None,
210                    }),
211                    on_edit: Some(LifecycleBeforeAfterScripts {
212                        after: Some(vec![
213                            [
214                                "hooks/posts/target/release/posts",
215                                "after_edit",
216                                "content/posts",
217                            ]
218                            .iter()
219                            .map(ToString::to_string)
220                            .collect::<Vec<_>>(),
221                        ]),
222                        before: None,
223                    }),
224                    on_delete: Some(LifecycleBeforeAfterScripts {
225                        after: Some(vec![
226                            [
227                                "hooks/posts/target/release/posts",
228                                "after_delete",
229                                "content/posts",
230                            ]
231                            .iter()
232                            .map(ToString::to_string)
233                            .collect::<Vec<_>>(),
234                        ]),
235                        before: None,
236                    }),
237                }),
238            }],
239            ..Default::default()
240        }),
241
242        templates: Some(vec![
243            TemplateConfig {
244                ffi: TemplateFfi {
245                    version: TemplateFfiVersion::V1,
246                    serialization: TemplateFfiSerialization::FlexBufferVector,
247                },
248                idx: 0,
249                name: "index".to_string(),
250                mime: "text/html".to_string(),
251                path: Some("./templates/index.html".to_string()),
252                route: "/".to_string(),
253                globals: Some(vec!["author".to_string()]),
254                content: Some(vec![TemplateRef {
255                    idx: 0,
256                    name: "post".to_string(),
257                    fields: vec![
258                        TemplateRefField {
259                            idx: 0,
260                            name: "slug".to_string(),
261                            ..Default::default()
262                        },
263                        TemplateRefField {
264                            idx: 1,
265                            name: "title".to_string(),
266                            ..Default::default()
267                        },
268                        TemplateRefField {
269                            idx: 2,
270                            name: "date".to_string(),
271                            ..Default::default()
272                        },
273                    ],
274                    all: Some("posts".to_string()),
275                }]),
276                ..Default::default()
277            },
278            TemplateConfig {
279                ffi: TemplateFfi {
280                    version: TemplateFfiVersion::V1,
281                    serialization: TemplateFfiSerialization::FlexBufferVector,
282                },
283                idx: 1,
284                name: "post".to_string(),
285                mime: "text/html".to_string(),
286                path: Some("./templates/post.html".to_string()),
287                route: "/posts/{slug}".to_string(),
288                globals: Some(vec!["author".to_string()]),
289                content: Some(vec![TemplateRef {
290                    idx: 0,
291                    name: "post".to_string(),
292                    fields: vec![
293                        TemplateRefField {
294                            idx: 0,
295                            name: "slug".to_string(),
296                            bind: Some(Segment {
297                                name: "slug".to_string(),
298                                expression: None,
299                            }),
300                            ..Default::default()
301                        },
302                        TemplateRefField {
303                            idx: 1,
304                            name: "title".to_string(),
305                            ..Default::default()
306                        },
307                        TemplateRefField {
308                            idx: 2,
309                            name: "date".to_string(),
310                            ..Default::default()
311                        },
312                        TemplateRefField {
313                            idx: 3,
314                            name: "body".to_string(),
315                            ..Default::default()
316                        },
317                    ],
318                    all: None,
319                }]),
320                ..Default::default()
321            },
322        ]),
323        ..Default::default()
324    };
325
326    let ordinary_json = serde_json::to_string_pretty(&app_config)?;
327
328    let mut file = File::create(path.join("ordinary.json"))?;
329    file.write_all(ordinary_json.as_bytes())?;
330    file.flush()?;
331
332    let mut file = File::create(path.join("content.json"))?;
333    file.write_all(b"[]")?;
334    file.flush()?;
335
336    let first_post_json = serde_json::to_string_pretty(&ContentObject {
337        uuid: uuid::Uuid::new_v4().to_string(),
338        instance_of: "post".to_string(),
339        fields: vec![
340            ContentField {
341                name: "slug".to_string(),
342                value: Value::String("hello-world".to_string()),
343            },
344            ContentField {
345                name: "title".to_string(),
346                value: Value::String("Hello World!".to_string()),
347            },
348            ContentField {
349                name: "date".to_string(),
350                value: Value::Number(
351                    SystemTime::now()
352                        .duration_since(SystemTime::UNIX_EPOCH)?
353                        .as_secs()
354                        .into(),
355                ),
356            },
357            ContentField {
358                name: "body".to_string(),
359                value: Value::String(
360                    "This is your first blog post generated with [Ordinary](https://codeberg.org/ordinarylabs/Ordinary).".to_string(),
361                ),
362            },
363        ],
364    })?;
365
366    if let Some(path_str) = path.to_str() {
367        add_obj(path_str, &first_post_json)?;
368    }
369
370    let mut file = File::create(path.join(".gitignore"))?;
371    file.write_all(GITIGNORE)?;
372    file.flush()?;
373
374    let mut file = File::create(path.join("README.md"))?;
375    file.write_all(README)?;
376    file.flush()?;
377
378    let assets_dir = path.join("assets");
379    let themes_dir = assets_dir.join("themes");
380
381    fs_err::create_dir_all(&themes_dir)?;
382
383    let mut file = File::create(themes_dir.join("tmp.css"))?;
384    file.write_all(TMP_CSS)?;
385    file.flush()?;
386
387    let mut file = File::create(assets_dir.join("favicon.svg"))?;
388    file.write_all(FAVICON)?;
389    file.flush()?;
390
391    let fragments_dir = path.join("fragments");
392    fs_err::create_dir_all(&fragments_dir)?;
393
394    let mut header_file = File::create(fragments_dir.join("header.html"))?;
395    header_file.write_all(HEADER)?;
396    header_file.flush()?;
397
398    let mut footer_file = File::create(fragments_dir.join("footer.html"))?;
399    footer_file.write_all(FOOTER)?;
400    footer_file.flush()?;
401
402    let html_globals = Some(vec!["author".to_string()]);
403
404    if let Some(path_str) = path.to_str() {
405        let templates_dir = path.join("templates");
406        fs_err::create_dir_all(&templates_dir)?;
407
408        let mut index_file = File::create(templates_dir.join("index.html"))?;
409        index_file.write_all(INDEX)?;
410        index_file.flush()?;
411
412        let mut post_file = File::create(templates_dir.join("post.html"))?;
413        post_file.write_all(POST)?;
414        post_file.flush()?;
415
416        add_template(
417            path_str,
418            "error",
419            "/error",
420            "text/html",
421            r#"
422    <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
423    <link rel="stylesheet" href="/assets/themes/tmp.css">
424"#,
425            r#"{% include "fragments/header.html" %}"#,
426            r#"{% include "fragments/footer.html" %}"#,
427            true,
428            html_globals,
429            None,
430        )?;
431
432        add_template(
433            path_str,
434            "sitemap",
435            "/sitemap.xml",
436            "text/xml",
437            "",
438            "",
439            "",
440            false,
441            None,
442            Some(vec![TemplateRef {
443                idx: 0,
444                name: "post".to_string(),
445                fields: vec![
446                    TemplateRefField {
447                        idx: 0,
448                        name: "slug".to_string(),
449                        ..Default::default()
450                    },
451                    TemplateRefField {
452                        idx: 1,
453                        name: "date".to_string(),
454                        ..Default::default()
455                    },
456                ],
457                all: Some("posts".to_string()),
458            }]),
459        )?;
460
461        let mut sitemap_file = File::create(templates_dir.join("sitemap.xml"))?;
462        sitemap_file.write_all(SITEMAP)?;
463        sitemap_file.flush()?;
464
465        add_template(
466            path_str,
467            "robots",
468            "/robots.txt",
469            "text/plain",
470            "",
471            "",
472            "",
473            false,
474            None,
475            None,
476        )?;
477
478        let mut robots_file = File::create(templates_dir.join("robots.txt"))?;
479        robots_file.write_all(ROBOTS)?;
480        robots_file.flush()?;
481
482        add_template(
483            path_str,
484            "rss",
485            "/rss",
486            "application/rss+xml",
487            "",
488            "",
489            "",
490            false,
491            Some(vec!["author".to_string()]),
492            Some(vec![TemplateRef {
493                idx: 0,
494                name: "post".to_string(),
495                fields: vec![
496                    TemplateRefField {
497                        idx: 0,
498                        name: "slug".to_string(),
499                        ..Default::default()
500                    },
501                    TemplateRefField {
502                        idx: 1,
503                        name: "title".to_string(),
504                        ..Default::default()
505                    },
506                    TemplateRefField {
507                        idx: 2,
508                        name: "date".to_string(),
509                        ..Default::default()
510                    },
511                ],
512                all: Some("posts".to_string()),
513            }]),
514        )?;
515
516        let mut robots_file = File::create(templates_dir.join("rss.xml"))?;
517        robots_file.write_all(RSS)?;
518        robots_file.flush()?;
519    }
520
521    Ok(())
522}