add cargo

This commit is contained in:
2025-06-04 23:53:05 +09:00
parent e191cb376c
commit 02dd69840d
16 changed files with 1473 additions and 0 deletions

22
src/commands/build.rs Normal file
View File

@ -0,0 +1,22 @@
use anyhow::Result;
use colored::Colorize;
use std::path::PathBuf;
use crate::generator::Generator;
use crate::config::Config;
pub async fn execute(path: PathBuf) -> Result<()> {
println!("{}", "Building blog...".green());
// Load configuration
let config = Config::load(&path)?;
// Create generator
let generator = Generator::new(path, config)?;
// Build the site
generator.build().await?;
println!("{}", "Build completed successfully!".green().bold());
Ok(())
}

21
src/commands/clean.rs Normal file
View File

@ -0,0 +1,21 @@
use anyhow::Result;
use colored::Colorize;
use std::fs;
use std::path::Path;
pub async fn execute() -> Result<()> {
println!("{}", "Cleaning build artifacts...".yellow());
let public_dir = Path::new("public");
if public_dir.exists() {
fs::remove_dir_all(public_dir)?;
println!("{} public directory", "Removed".cyan());
} else {
println!("{}", "No build artifacts to clean");
}
println!("{}", "Clean completed!".green().bold());
Ok(())
}

216
src/commands/init.rs Normal file
View File

@ -0,0 +1,216 @@
use anyhow::Result;
use colored::Colorize;
use std::fs;
use std::path::PathBuf;
pub async fn execute(path: PathBuf) -> Result<()> {
println!("{}", "Initializing new blog...".green());
// Create directory structure
let dirs = vec![
"content",
"content/posts",
"templates",
"static",
"static/css",
"static/js",
"static/images",
"public",
];
for dir in dirs {
let dir_path = path.join(dir);
fs::create_dir_all(&dir_path)?;
println!(" {} {}", "Created".cyan(), dir_path.display());
}
// Create default config
let config_content = r#"[site]
title = "My Blog"
description = "A blog powered by ailog"
base_url = "https://example.com"
language = "ja"
[build]
highlight_code = true
minify = false
[ai]
enabled = false
auto_translate = false
comment_moderation = false
"#;
fs::write(path.join("config.toml"), config_content)?;
println!(" {} config.toml", "Created".cyan());
// Create default template
let base_template = r#"<!DOCTYPE html>
<html lang="{{ config.language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.title }}{% endblock %}</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<h1><a href="/">{{ config.title }}</a></h1>
<p>{{ config.description }}</p>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>&copy; 2025 {{ config.title }}</p>
</footer>
</body>
</html>"#;
fs::write(path.join("templates/base.html"), base_template)?;
println!(" {} templates/base.html", "Created".cyan());
let index_template = r#"{% extends "base.html" %}
{% block content %}
<h2>Recent Posts</h2>
<ul class="post-list">
{% for post in posts %}
<li>
<a href="{{ post.url }}">{{ post.title }}</a>
<time>{{ post.date }}</time>
</li>
{% endfor %}
</ul>
{% endblock %}"#;
fs::write(path.join("templates/index.html"), index_template)?;
println!(" {} templates/index.html", "Created".cyan());
let post_template = r#"{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<article>
<h1>{{ post.title }}</h1>
<time>{{ post.date }}</time>
<div class="content">
{{ post.content | safe }}
</div>
</article>
{% endblock %}"#;
fs::write(path.join("templates/post.html"), post_template)?;
println!(" {} templates/post.html", "Created".cyan());
// Create default CSS
let css_content = r#"body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
header {
margin-bottom: 40px;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
}
header h1 {
margin: 0;
}
header h1 a {
color: #333;
text-decoration: none;
}
.post-list {
list-style: none;
padding: 0;
}
.post-list li {
margin-bottom: 15px;
}
.post-list time {
color: #666;
font-size: 0.9em;
margin-left: 10px;
}
article time {
color: #666;
display: block;
margin-bottom: 20px;
}
pre {
background-color: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
code {
background-color: #f4f4f4;
padding: 2px 5px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
}"#;
fs::write(path.join("static/css/style.css"), css_content)?;
println!(" {} static/css/style.css", "Created".cyan());
// Create sample post
let sample_post = r#"---
title: "Welcome to ailog"
date: 2025-01-06
tags: ["welcome", "ailog"]
---
# Welcome to ailog
This is your first post powered by **ailog** - a static blog generator with AI features.
## Features
- Fast static site generation
- Markdown support with frontmatter
- AI-powered features (coming soon)
- atproto integration for comments
## Getting Started
Create new posts with:
```bash
ailog new "My New Post"
```
Build your blog with:
```bash
ailog build
```
Happy blogging!"#;
fs::write(path.join("content/posts/welcome.md"), sample_post)?;
println!(" {} content/posts/welcome.md", "Created".cyan());
println!("\n{}", "Blog initialized successfully!".green().bold());
println!("\nNext steps:");
println!(" 1. cd {}", path.display());
println!(" 2. ailog build");
println!(" 3. ailog serve");
Ok(())
}

5
src/commands/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod init;
pub mod build;
pub mod new;
pub mod serve;
pub mod clean;

48
src/commands/new.rs Normal file
View File

@ -0,0 +1,48 @@
use anyhow::Result;
use chrono::Local;
use colored::Colorize;
use std::fs;
use std::path::PathBuf;
pub async fn execute(title: String, format: String) -> Result<()> {
println!("{} {}", "Creating new post:".green(), title);
let date = Local::now();
let filename = format!(
"{}-{}.{}",
date.format("%Y-%m-%d"),
title.to_lowercase().replace(' ', "-"),
format
);
let content = format!(
r#"---
title: "{}"
date: {}
tags: []
draft: false
---
# {}
Write your content here...
"#,
title,
date.format("%Y-%m-%d"),
title
);
let post_path = PathBuf::from("content/posts").join(&filename);
// Ensure directory exists
if let Some(parent) = post_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&post_path, content)?;
println!("{} {}", "Created:".cyan(), post_path.display());
println!("\nYou can now edit your post at: {}", post_path.display());
Ok(())
}

77
src/commands/serve.rs Normal file
View File

@ -0,0 +1,77 @@
use anyhow::Result;
use colored::Colorize;
use std::path::PathBuf;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
pub async fn execute(port: u16) -> Result<()> {
let addr = format!("127.0.0.1:{}", port);
let listener = TcpListener::bind(&addr).await?;
println!("{}", "Starting development server...".green());
println!("Serving at: {}", format!("http://{}", addr).blue().underline());
println!("Press Ctrl+C to stop\n");
loop {
let (stream, _) = listener.accept().await?;
tokio::spawn(handle_connection(stream));
}
}
async fn handle_connection(mut stream: TcpStream) -> Result<()> {
let mut buffer = [0; 1024];
stream.read(&mut buffer).await?;
let request = String::from_utf8_lossy(&buffer[..]);
let path = parse_request_path(&request);
let (status, content_type, content) = match serve_file(&path).await {
Ok((ct, data)) => ("200 OK", ct, data),
Err(_) => ("404 NOT FOUND", "text/html", b"<h1>404 - Not Found</h1>".to_vec()),
};
let response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n",
status,
content_type,
content.len()
);
stream.write_all(response.as_bytes()).await?;
stream.write_all(&content).await?;
stream.flush().await?;
Ok(())
}
fn parse_request_path(request: &str) -> String {
request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/")
.to_string()
}
async fn serve_file(path: &str) -> Result<(&'static str, Vec<u8>)> {
let file_path = if path == "/" {
PathBuf::from("public/index.html")
} else {
PathBuf::from("public").join(path.trim_start_matches('/'))
};
let content_type = match file_path.extension().and_then(|ext| ext.to_str()) {
Some("html") => "text/html",
Some("css") => "text/css",
Some("js") => "application/javascript",
Some("json") => "application/json",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
_ => "text/plain",
};
let content = tokio::fs::read(file_path).await?;
Ok((content_type, content))
}

63
src/config.rs Normal file
View File

@ -0,0 +1,63 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub site: SiteConfig,
pub build: BuildConfig,
pub ai: Option<AiConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SiteConfig {
pub title: String,
pub description: String,
pub base_url: String,
pub language: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BuildConfig {
pub highlight_code: bool,
pub minify: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AiConfig {
pub enabled: bool,
pub auto_translate: bool,
pub comment_moderation: bool,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let config_path = path.join("config.toml");
let content = fs::read_to_string(config_path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
}
impl Default for Config {
fn default() -> Self {
Self {
site: SiteConfig {
title: "My Blog".to_string(),
description: "A blog powered by ailog".to_string(),
base_url: "https://example.com".to_string(),
language: "ja".to_string(),
},
build: BuildConfig {
highlight_code: true,
minify: false,
},
ai: Some(AiConfig {
enabled: false,
auto_translate: false,
comment_moderation: false,
}),
}
}
}

178
src/generator.rs Normal file
View File

@ -0,0 +1,178 @@
use anyhow::Result;
use colored::Colorize;
use std::path::PathBuf;
use walkdir::WalkDir;
use std::fs;
use crate::config::Config;
use crate::markdown::MarkdownProcessor;
use crate::template::TemplateEngine;
pub struct Generator {
base_path: PathBuf,
config: Config,
markdown_processor: MarkdownProcessor,
template_engine: TemplateEngine,
}
impl Generator {
pub fn new(base_path: PathBuf, config: Config) -> Result<Self> {
let markdown_processor = MarkdownProcessor::new(config.build.highlight_code);
let template_engine = TemplateEngine::new(base_path.join("templates"))?;
Ok(Self {
base_path,
config,
markdown_processor,
template_engine,
})
}
pub async fn build(&self) -> Result<()> {
// Clean public directory
let public_dir = self.base_path.join("public");
if public_dir.exists() {
fs::remove_dir_all(&public_dir)?;
}
fs::create_dir_all(&public_dir)?;
// Copy static files
self.copy_static_files()?;
// Process posts
let posts = self.process_posts().await?;
// Generate index page
self.generate_index(&posts).await?;
// Generate post pages
for post in &posts {
self.generate_post_page(post).await?;
}
println!("{} {} posts", "Generated".cyan(), posts.len());
Ok(())
}
fn copy_static_files(&self) -> Result<()> {
let static_dir = self.base_path.join("static");
let public_dir = self.base_path.join("public");
if static_dir.exists() {
for entry in WalkDir::new(&static_dir).min_depth(1) {
let entry = entry?;
let path = entry.path();
let relative_path = path.strip_prefix(&static_dir)?;
let dest_path = public_dir.join(relative_path);
if path.is_dir() {
fs::create_dir_all(&dest_path)?;
} else {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(path, &dest_path)?;
}
}
println!("{} static files", "Copied".cyan());
}
Ok(())
}
async fn process_posts(&self) -> Result<Vec<Post>> {
let mut posts = Vec::new();
let posts_dir = self.base_path.join("content/posts");
if posts_dir.exists() {
for entry in WalkDir::new(&posts_dir).min_depth(1) {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
match self.process_single_post(path).await {
Ok(post) => posts.push(post),
Err(e) => eprintln!("Error processing {}: {}", path.display(), e),
}
}
}
}
// Sort posts by date (newest first)
posts.sort_by(|a, b| b.date.cmp(&a.date));
Ok(posts)
}
async fn process_single_post(&self, path: &std::path::Path) -> Result<Post> {
let content = fs::read_to_string(path)?;
let (frontmatter, content) = self.markdown_processor.parse_frontmatter(&content)?;
let html_content = self.markdown_processor.render(&content)?;
let slug = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("post")
.to_string();
let post = Post {
title: frontmatter.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled")
.to_string(),
date: frontmatter.get("date")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
content: html_content,
slug: slug.clone(),
url: format!("/posts/{}.html", slug),
tags: frontmatter.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect())
.unwrap_or_default(),
};
Ok(post)
}
async fn generate_index(&self, posts: &[Post]) -> Result<()> {
let context = self.template_engine.create_context(&self.config, posts)?;
let html = self.template_engine.render("index.html", &context)?;
let output_path = self.base_path.join("public/index.html");
fs::write(output_path, html)?;
Ok(())
}
async fn generate_post_page(&self, post: &Post) -> Result<()> {
let mut context = tera::Context::new();
context.insert("config", &self.config.site);
context.insert("post", post);
let html = self.template_engine.render_with_context("post.html", &context)?;
let output_dir = self.base_path.join("public/posts");
fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(format!("{}.html", post.slug));
fs::write(output_path, html)?;
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Post {
pub title: String,
pub date: String,
pub content: String,
pub slug: String,
pub url: String,
pub tags: Vec<String>,
}

75
src/main.rs Normal file
View File

@ -0,0 +1,75 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod commands;
mod generator;
mod markdown;
mod template;
mod config;
#[derive(Parser)]
#[command(name = "ailog")]
#[command(about = "A static blog generator with AI features")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new blog
Init {
/// Path to create the blog
#[arg(default_value = ".")]
path: PathBuf,
},
/// Build the blog
Build {
/// Path to the blog directory
#[arg(default_value = ".")]
path: PathBuf,
},
/// Create a new post
New {
/// Title of the post
title: String,
/// Post format
#[arg(short, long, default_value = "md")]
format: String,
},
/// Serve the blog locally
Serve {
/// Port to serve on
#[arg(short, long, default_value = "8080")]
port: u16,
},
/// Clean build artifacts
Clean,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { path } => {
commands::init::execute(path).await?;
}
Commands::Build { path } => {
commands::build::execute(path).await?;
}
Commands::New { title, format } => {
commands::new::execute(title, format).await?;
}
Commands::Serve { port } => {
commands::serve::execute(port).await?;
}
Commands::Clean => {
commands::clean::execute().await?;
}
}
Ok(())
}

138
src/markdown.rs Normal file
View File

@ -0,0 +1,138 @@
use anyhow::Result;
use pulldown_cmark::{html, Options, Parser, CodeBlockKind};
use syntect::parsing::SyntaxSet;
use syntect::highlighting::ThemeSet;
use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
use gray_matter::Matter;
use gray_matter::engine::YAML;
use serde_json::Value;
pub struct MarkdownProcessor {
highlight_code: bool,
syntax_set: SyntaxSet,
theme_set: ThemeSet,
}
impl MarkdownProcessor {
pub fn new(highlight_code: bool) -> Self {
Self {
highlight_code,
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
}
}
pub fn parse_frontmatter(&self, content: &str) -> Result<(serde_json::Map<String, Value>, String)> {
let matter = Matter::<YAML>::new();
let result = matter.parse(content);
let frontmatter = result.data
.and_then(|pod| pod.as_hashmap().ok())
.map(|map| {
let mut json_map = serde_json::Map::new();
for (k, v) in map {
// Keys in hashmap are already strings
let value = self.pod_to_json_value(v);
json_map.insert(k, value);
}
json_map
})
.unwrap_or_default();
Ok((frontmatter, result.content))
}
fn pod_to_json_value(&self, pod: gray_matter::Pod) -> Value {
match pod {
gray_matter::Pod::Null => Value::Null,
gray_matter::Pod::Boolean(b) => Value::Bool(b),
gray_matter::Pod::Integer(i) => Value::Number(serde_json::Number::from(i)),
gray_matter::Pod::Float(f) => serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null),
gray_matter::Pod::String(s) => Value::String(s),
gray_matter::Pod::Array(arr) => {
Value::Array(arr.into_iter().map(|p| self.pod_to_json_value(p)).collect())
}
gray_matter::Pod::Hash(map) => {
let mut json_map = serde_json::Map::new();
for (k, v) in map {
json_map.insert(k, self.pod_to_json_value(v));
}
Value::Object(json_map)
}
}
}
pub fn render(&self, content: &str) -> Result<String> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_TASKLISTS);
if self.highlight_code {
self.render_with_syntax_highlighting(content, options)
} else {
let parser = Parser::new_ext(content, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
Ok(html_output)
}
}
fn render_with_syntax_highlighting(&self, content: &str, options: Options) -> Result<String> {
let parser = Parser::new_ext(content, options);
let mut html_output = String::new();
let mut code_block = None;
let theme = &self.theme_set.themes["base16-ocean.dark"];
let mut events = Vec::new();
for event in parser {
match event {
pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(kind)) => {
if let CodeBlockKind::Fenced(lang) = &kind {
code_block = Some((String::new(), lang.to_string()));
}
}
pulldown_cmark::Event::Text(text) => {
if let Some((ref mut code, _)) = code_block {
code.push_str(&text);
} else {
events.push(pulldown_cmark::Event::Text(text));
}
}
pulldown_cmark::Event::End(pulldown_cmark::TagEnd::CodeBlock) => {
if let Some((code, lang)) = code_block.take() {
let highlighted = self.highlight_code_block(&code, &lang, theme);
events.push(pulldown_cmark::Event::Html(highlighted.into()));
}
}
_ => events.push(event),
}
}
html::push_html(&mut html_output, events.into_iter());
Ok(html_output)
}
fn highlight_code_block(&self, code: &str, lang: &str, theme: &syntect::highlighting::Theme) -> String {
let syntax = self.syntax_set
.find_syntax_by_token(lang)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
let mut output = String::from("<pre><code>");
for line in code.lines() {
let ranges = highlighter.highlight_line(line, &self.syntax_set).unwrap();
let html_line = styled_line_to_highlighted_html(&ranges[..], IncludeBackground::No).unwrap();
output.push_str(&html_line);
output.push('\n');
}
output.push_str("</code></pre>");
output
}
}

35
src/template.rs Normal file
View File

@ -0,0 +1,35 @@
use anyhow::Result;
use tera::{Tera, Context};
use std::path::PathBuf;
use crate::config::Config;
use crate::generator::Post;
pub struct TemplateEngine {
tera: Tera,
}
impl TemplateEngine {
pub fn new(template_dir: PathBuf) -> Result<Self> {
let pattern = format!("{}/**/*.html", template_dir.display());
let tera = Tera::new(&pattern)?;
Ok(Self { tera })
}
pub fn create_context(&self, config: &Config, posts: &[Post]) -> Result<Context> {
let mut context = Context::new();
context.insert("config", &config.site);
context.insert("posts", posts);
Ok(context)
}
pub fn render(&self, template: &str, context: &Context) -> Result<String> {
let output = self.tera.render(template, context)?;
Ok(output)
}
pub fn render_with_context(&self, template: &str, context: &Context) -> Result<String> {
let output = self.tera.render(template, context)?;
Ok(output)
}
}