ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! `ocre generate seo`: `src/seo.rs`, the list of the app's public pages and
//! the routes search engines and language models read from it:
//! `/sitemap.xml` (every locale of each page when the app has translations)
//! and `/llms.txt`.

use super::{Edits, read_config, register_routes};
use crate::{CliResult, names::humanize, project::Project};

pub fn seo(project: &Project) -> CliResult {
    let mut edits = Edits::new(project);
    let app = humanize(read_config(&edits)?.worker_name()?);
    let localized = edits.read("src/lib.rs")?.is_some_and(|lib| lib.contains("static LOCALES"));
    edits.create("src/seo.rs", seo_rs(&app, localized))?;
    if let Some(vars) = edits.read(".dev.vars")?
        && !vars.lines().any(|line| line.split('=').next().is_some_and(|key| key.trim() == "APP_URL"))
    {
        let vars = if vars.is_empty() || vars.ends_with('\n') { vars } else { format!("{vars}\n") };
        edits.update(".dev.vars", format!("{vars}APP_URL=http://localhost:8787\n"));
    }
    register_routes(&mut edits, "seo")?;
    let mut report = edits.apply("generate seo")?;
    report.next = vec![
        "list the public pages in PAGES (src/seo.rs), and their records in `sitemap`".to_owned(),
        "ocre dev, then open http://localhost:8787/sitemap.xml and http://localhost:8787/llms.txt".to_owned(),
        "production: APP_URL (the public origin) in worker.env of cloudflare.config.ts; add `Sitemap: https://<your host>/sitemap.xml` to public/robots.txt".to_owned(),
    ];
    Ok(report)
}

fn seo_rs(app: &str, localized: bool) -> String {
    let (doc, add) = if localized {
        (
            "//! The app has translations: each page is listed in every locale\n//! (`/<locale><path>`), with `hreflang` alternates.",
            "        let urls = crate::LOCALES.locale(crate::LOCALES.default_locale()).alternates(&base, page.path);\n        \
             let urls: Vec<(&str, &str)> = urls.iter().map(|(code, url)| (*code, url.as_str())).collect();\n        \
             sitemap.add_localized(&urls, None);",
        )
    } else {
        (
            "//! Pages are listed as they are; with translations (`ocre g locale`),\n//! use `Sitemap::add_localized` and `i18n.alternates`.",
            "        sitemap.add(SitemapUrl { loc: format!(\"{base}{}\", page.path), lastmod: None, alternates: vec![] });",
        )
    };
    let link = if localized {
        "crate::LOCALES.locale(crate::LOCALES.default_locale()).path(page.path)"
    } else {
        "page.path.to_owned()"
    };
    let import = if localized { "Sitemap" } else { "Sitemap, SitemapUrl" };
    format!(
        r#"//! What search engines and language models read: `/sitemap.xml` and
//! `/llms.txt`, both built from `PAGES`. Generated by `ocre g seo`.
{doc}
//! Pages also need their `<title>`, a description, and in their `<head>`
//! `{{{{ i18n.alternate_links(base_url, path)|safe }}}}` (translated pages) and
//! `{{{{ ocre::seo::json_ld(&data)|safe }}}}` (structured data).

use axum::{{Router, extract::State, routing::get}};
use ocre::{{
    Ctx, Result,
    seo::{{LlmsTxt, {import}}},
}};

/// A public page, by its path without a locale prefix.
pub struct Page {{
    pub path: &'static str,
    pub title: &'static str,
    /// One sentence, for `llms.txt`.
    pub description: &'static str,
}}

/// The public pages, one line each; the sitemap and `llms.txt` list them.
pub const PAGES: &[Page] = &[Page {{ path: "/", title: "{app}", description: "The home page." }}];

pub fn routes() -> Router<Ctx> {{
    Router::new().route("/sitemap.xml", get(sitemap)).route("/llms.txt", get(llms))
}}

/// `APP_URL`, the app's public origin (`https://example.com`).
fn base_url(ctx: &Ctx) -> Result<String> {{
    Ok(ocre::mail::url(ctx, "/")?.trim_end_matches('/').to_owned())
}}

async fn sitemap(State(ctx): State<Ctx>) -> Result<Sitemap> {{
    let base = base_url(&ctx)?;
    let mut sitemap = Sitemap::new();
    for page in PAGES {{
{add}
    }}
    // Records with a public page, e.g. (`updated_at` is `YYYY-MM-DD HH:MM:SS`):
    // for post in crate::models::post::query().order_desc("id").limit(1000).all(&ctx.db()?).await? {{
    //     let lastmod = post.updated_at.get(..10).map(str::to_owned);
    //     sitemap.add(SitemapUrl {{ loc: format!("{{base}}/posts/{{}}", post.id), lastmod, alternates: vec![] }});
    // }}
    Ok(sitemap)
}}

async fn llms(State(ctx): State<Ctx>) -> Result<LlmsTxt> {{
    let base = base_url(&ctx)?;
    let mut txt = LlmsTxt::new("{app}", "What {app} is, in one sentence.");
    for page in PAGES {{
        txt = txt.link("Pages", page.title, &format!("{{base}}{{}}", {link}), page.description);
    }}
    Ok(txt)
}}
"#
    )
}