nowaki 0.12.0

A fast full-stack web framework with a Rust toolchain and islands architecture
//! 型付きルートの生成。`routes/` を走査して `.nowaki/types.d.ts` を書き出す。
//! 生成物は `@nowaki-dev/runtime/navigation` の `NowakiRoutes` を拡張し、
//! `route()` / `Link` の href とパラメータを型安全にする(Next の typedRoutes 相当)。
//!
//! `nowaki typegen` で手動実行できるほか、dev 起動時と build 時に自動で走る。

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

const ROUTE_EXT: [&str; 4] = ["tsx", "ts", "jsx", "js"];

/// routes/ を走査し「URL パターン → パラメータ型」を返す(パス昇順・重複排除)。
fn scan(routes_dir: &Path) -> Vec<(String, String)> {
    let mut files = Vec::new();
    walk(routes_dir, &mut files);
    files.sort();

    let mut out: Vec<(String, String)> = Vec::new();
    for abs in files {
        let rel = match abs.strip_prefix(routes_dir) {
            Ok(r) => r.to_string_lossy().replace('\\', "/"),
            Err(_) => continue,
        };
        if let Some(entry) = route_entry(&rel) {
            out.push(entry);
        }
    }
    out.sort();
    out.dedup();
    out
}

/// routes/ 相対パス(例 "blog/[slug].tsx")から (URL パターン, パラメータ型) を求める。
/// ルートにならないもの(規約ファイル・boundary・API・非対象拡張子)は None。
fn route_entry(rel: &str) -> Option<(String, String)> {
    let rel = rel.replace('\\', "/");
    let (stem_path, ext) = rel.rsplit_once('.')?;
    if !ROUTE_EXT.contains(&ext) {
        return None;
    }
    let base = stem_path.rsplit('/').next().unwrap_or(stem_path);
    // 規約ファイル(_layout/_middleware/_404/_500/_error)と boundary、API は除外。
    if base.starts_with('_') || base == "loading" || base == "error" {
        return None;
    }
    if rel == "api" || rel.starts_with("api/") {
        return None;
    }

    let mut url = format!("/{stem_path}");
    if let Some(stripped) = url.strip_suffix("/index") {
        url = stripped.to_string();
    }
    if url.is_empty() {
        url = "/".to_string();
    }

    // [name] → string、[...name] → string[]。
    let mut params: Vec<String> = Vec::new();
    for seg in url.split('/').filter(|s| !s.is_empty()) {
        if let Some(name) = seg.strip_prefix("[...").and_then(|s| s.strip_suffix(']')) {
            params.push(format!("{name}: string[]"));
        } else if let Some(name) = seg.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
            params.push(format!("{name}: string"));
        }
    }
    let params_ty = if params.is_empty() {
        "{}".to_string()
    } else {
        format!("{{ {} }}", params.join("; "))
    };
    Some((url, params_ty))
}

/// .d.ts の本文を組み立てる。
fn render(routes: &[(String, String)]) -> String {
    let mut s = String::new();
    s.push_str("// AUTO-GENERATED by `nowaki typegen` — do not edit.\n");
    s.push_str("// Typed routes for @nowaki-dev/runtime/navigation (route() / <Link>).\n");
    s.push_str("import \"@nowaki-dev/runtime/navigation\";\n\n");
    s.push_str("declare module \"@nowaki-dev/runtime/navigation\" {\n");
    s.push_str("  interface NowakiRoutes {\n");
    for (path, params) in routes {
        s.push_str(&format!("    \"{path}\": {params};\n"));
    }
    s.push_str("  }\n}\n");
    s
}

/// `.nowaki/types.d.ts` を書き出す(内容が同じなら書かない=dev 監視を無駄に起こさない)。
pub fn write(root: &Path) -> Result<()> {
    let routes_dir = root.join("routes");
    if !routes_dir.is_dir() {
        return Ok(());
    }
    let routes = scan(&routes_dir);
    let out_dir = root.join(".nowaki");
    std::fs::create_dir_all(&out_dir)
        .with_context(|| format!("作成失敗: {}", out_dir.display()))?;
    let content = render(&routes);
    let path = out_dir.join("types.d.ts");
    if std::fs::read_to_string(&path).ok().as_deref() != Some(content.as_str()) {
        std::fs::write(&path, &content)
            .with_context(|| format!("書き込み失敗: {}", path.display()))?;
    }
    Ok(())
}

/// `nowaki typegen` の実体。生成したルート数を報告する。
pub fn run(root: PathBuf) -> Result<()> {
    let routes_dir = root.join("routes");
    if !routes_dir.is_dir() {
        anyhow::bail!("routes/ が見つかりません({})。", root.display());
    }
    let n = scan(&routes_dir).len();
    write(&root)?;
    println!(
        "{} {} route(s) → {}",
        crate::ui::green("✓ typegen"),
        n,
        crate::ui::dim(".nowaki/types.d.ts")
    );
    Ok(())
}

fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            walk(&path, out);
        } else {
            out.push(path);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{render, route_entry};

    fn entry(rel: &str) -> Option<(String, String)> {
        route_entry(rel)
    }

    #[test]
    fn static_and_index_routes() {
        assert_eq!(entry("index.tsx"), Some(("/".into(), "{}".into())));
        assert_eq!(entry("about.tsx"), Some(("/about".into(), "{}".into())));
        assert_eq!(entry("blog/index.tsx"), Some(("/blog".into(), "{}".into())));
    }

    #[test]
    fn dynamic_and_catch_all_params() {
        assert_eq!(
            entry("blog/[slug].tsx"),
            Some(("/blog/[slug]".into(), "{ slug: string }".into()))
        );
        assert_eq!(
            entry("files/[...path].tsx"),
            Some(("/files/[...path]".into(), "{ path: string[] }".into()))
        );
        assert_eq!(
            entry("users/[id]/posts/[pid].tsx"),
            Some((
                "/users/[id]/posts/[pid]".into(),
                "{ id: string; pid: string }".into()
            ))
        );
    }

    #[test]
    fn conventions_and_api_are_excluded() {
        assert_eq!(entry("_layout.tsx"), None);
        assert_eq!(entry("_404.tsx"), None);
        assert_eq!(entry("_middleware.ts"), None);
        assert_eq!(entry("loading.tsx"), None);
        assert_eq!(entry("error.tsx"), None);
        assert_eq!(entry("api/hello.ts"), None);
        assert_eq!(entry("api/posts/[id].ts"), None);
        assert_eq!(entry("readme.md"), None); // 非対象拡張子
    }

    #[test]
    fn render_emits_module_augmentation() {
        let out = render(&[
            ("/".into(), "{}".into()),
            ("/blog/[slug]".into(), "{ slug: string }".into()),
        ]);
        assert!(out.contains("declare module \"@nowaki-dev/runtime/navigation\""));
        assert!(out.contains("\"/\": {};"));
        assert!(out.contains("\"/blog/[slug]\": { slug: string };"));
    }
}