add cargo

This commit is contained in:
syui 2025-06-04 23:53:05 +09:00
parent e191cb376c
commit 02dd69840d
Signed by: syui
GPG Key ID: 5417CFEBAD92DF56
16 changed files with 1473 additions and 0 deletions

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(cargo init:*)",
"Bash(cargo:*)"
],
"deny": []
}
}

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/target
/Cargo.lock
/public
*.swp
*.swo
*~
.DS_Store

28
Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "ailog"
version = "0.1.0"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
license = "MIT"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
pulldown-cmark = "0.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.40", features = ["full"] }
anyhow = "1.0"
toml = "0.8"
chrono = "0.4"
tera = "1.20"
walkdir = "2.5"
gray_matter = "0.2"
fs_extra = "1.3"
colored = "2.1"
serde_yaml = "0.9"
syntect = "5.2"
reqwest = { version = "0.12", features = ["json"] }
[dev-dependencies]
tempfile = "3.14"

View File

@ -0,0 +1,73 @@
# ai.log
A Rust-based static blog generator with AI integration capabilities.
## Overview
ai.log is part of the ai ecosystem - a static site generator that creates blogs with built-in AI features for content enhancement and atproto integration.
## Features
- Static blog generation (inspired by Zola)
- AI-powered article editing and enhancement
- Automatic translation (ja → en)
- AI comment system integrated with atproto
- OAuth authentication via atproto accounts
## Installation
```bash
cargo install ailog
```
## Usage
```bash
# Initialize a new blog
ailog init myblog
# Create a new post
ailog new "My First Post"
# Build the blog
ailog build
# Serve locally
ailog serve
# Clean build files
ailog clean
```
## Configuration
Configuration files are stored in `~/.config/syui/ai/log/`
## AI Integration (Planned)
- Automatic content suggestions and corrections
- Multi-language support with AI translation
- AI-generated comments linked to atproto accounts
## atproto Integration (Planned)
Implements OAuth 2.0 for user authentication:
- Users can comment using their atproto accounts
- Comments are stored in atproto collections
- Full data sovereignty for users
## Build & Deploy
Designed for GitHub Actions and Cloudflare Pages deployment. Push to main branch triggers automatic build and deploy.
## Development Status
Currently implemented:
- Basic static site generation
- Markdown parsing and HTML generation
- Template system
- Development server
## License
© syui

478
claude.md Normal file
View File

@ -0,0 +1,478 @@
# エコシステム統合設計書
## 中核思想
- **存在子理論**: この世界で最も小さいもの(存在子/aiの探求
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
- **現実の反映**: 現実→ゲーム→現実の循環的影響
## システム構成図
```
存在子(ai) - 最小単位の意識
[ai.moji] 文字システム
[ai.os] + [ai.game device] ← 統合ハードウェア
├── ai.shell (Claude Code的機能)
├── ai.gpt (自律人格・記憶システム)
├── ai.log (AIと連携するブログシステム)
├── ai.ai (個人特化AI・心を読み取るAI)
├── ai.card (カードゲーム・iOS/Web/API)
└── ai.bot (分散SNS連携・カード配布)
[ai.verse] メタバース
├── world system (惑星型3D世界)
├── at system (atproto/分散SNS)
├── yui system (唯一性担保)
└── ai system (存在属性)
```
## 名前規則
名前規則は他のprojectと全て共通しています。exampleを示しますので、このルールに従ってください。
ここでは`ai.os`の場合の名前規則の例を記述します。
name: ai.os
- **[ "package", "code", "command" ]**: aios
- **[ "dir", "url" ]**: ai/os
- **[ "domain", "json" ]**: ai.os
```sh
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.os
{ "type": "os" }
```
```json
{
"ai": {
"os":{}
}
}
```
他のprojectも同じ名前規則を採用します。`ai.gpt`ならpackageは`aigpt`です。
## config(設定ファイル, env, 環境依存)
`config`を置く場所は統一されており、各projectの名前規則の`dir`項目を使用します。例えば、aiosの場合は`~/.config/syui/ai/os/`以下となります。pythonなどを使用する場合、`python -m venv`などでこのpackage config dirに環境を構築して実行するようにしてください。
domain形式を採用して、私は各projectを`git.syui.ai/ai`にhostしていますから、`~/.config/syui/ai`とします。
```sh
[syui.ai]
syui/ai
```
```sh
# example
~/.config/syui/ai
├── card
├── gpt
├── os
└── shell
```
## 各システム詳細
### ai.gpt - 自律的送信AI
**目的**: 関係性に基づく自発的コミュニケーション
**中核概念**:
- **人格**: 記憶(過去の発話)と関係性パラメータで構成
- **唯一性**: atproto accountとの1:1紐付け、改変不可能
- **自律送信**: 関係性が閾値を超えると送信機能が解禁
**技術構成**:
- `MemoryManager`: 完全ログ→AI要約→コア判定→選択的忘却
- `RelationshipTracker`: 時間減衰・日次制限付き関係性スコア
- `TransmissionController`: 閾値判定・送信トリガー
- `Persona`: AI運勢1-10ランダムによる人格変動
**実装仕様**:
```
- 言語: Python (fastapi_mcp)
- ストレージ: JSON/SQLite選択式
- インターフェース: Python CLI (click/typer)
- スケジューリング: cron-like自律処理
```
### ai.card - カードゲームシステム
**目的**: atproto基盤でのユーザーデータ主権カードゲーム
**現在の状況**:
- ai.botの機能として実装済み
- atproto accountでmentionすると1日1回カードを取得
- ai.api (MCP server予定) でユーザー管理
**移行計画**:
- **iOS移植**: Claudeが担当予定
- **データ保存**: atproto collection recordに保存ユーザーがデータを所有
- **不正防止**: OAuth 2.1 scope (実装待ち) + MCP serverで対応
- **画像ファイル**: Cloudflare Pagesが最適
**yui system適用**:
- カードの効果がアカウント固有
- 改ざん防止によるゲームバランス維持
- 将来的にai.verseとの統合で固有スキルと連動
### ai.ai - 心を読み取るAI
**目的**: 個人特化型AI・深層理解システム
**ai.gptとの関係**:
- ai.gpt → ai.ai: 自律送信AIから心理分析AIへの連携
- 関係性パラメータの深層分析
- ユーザーの思想コア部分の特定支援
### ai.verse - UEメタバース
**目的**: 現実反映型3D世界
**yui system実装**:
- キャラクター ↔ プレイヤー 1:1紐付け
- unique skill: そのプレイヤーのみ使用可能
- 他プレイヤーは同キャラでも同スキル使用不可
**統合要素**:
- ai.card: ゲーム内アイテムとしてのカード
- ai.gpt: NPCとしての自律AI人格
- atproto: ゲーム内プロフィール連携
## データフロー設計
### 唯一性担保の実装
```
現実の個人 → atproto account (DID) → ゲーム内avatar → 固有スキル
↑_______________________________| (現実の反映)
```
### AI駆動変換システム
```
遊び・創作活動 → ai.gpt分析 → 業務成果変換 → 企業価値創出
↑________________________| (Play-to-Work)
```
### カードゲーム・データ主権フロー
```
ユーザー → ai.bot mention → カード生成 → atproto collection → ユーザー所有
↑ ↓
← iOS app表示 ← ai.card API ←
```
## 技術スタック統合
### Core Infrastructure
- **OS**: Rust-based ai.os (Arch Linux base)
- **Container**: Docker image distribution
- **Identity**: atproto selfhost server + DID管理
- **AI**: fastapi_mcp server architecture
- **CLI**: Python unified (click/typer) - Rustから移行
### Game Engine Integration
- **Engine**: Unreal Engine (Blueprint)
- **Data**: atproto → UE → atproto sync
- **Avatar**: 分散SNS profile → 3D character
- **Streaming**: game screen = broadcast screen
### Mobile/Device
- **iOS**: ai.card移植 (Claude担当)
- **Hardware**: ai.game device (future)
- **Interface**: controller-first design
## 実装優先順位
### Phase 1: AI基盤強化 (現在進行)
- [ ] ai.gpt memory system完全実装
- 記憶の階層化(完全ログ→要約→コア→忘却)
- 関係性パラメータの時間減衰システム
- AI運勢による人格変動機能
- [ ] ai.card iOS移植
- atproto collection record連携
- MCP server化ai.api刷新
- [ ] fastapi_mcp統一基盤構築
### Phase 2: ゲーム統合
- [ ] ai.verse yui system実装
- unique skill機能
- atproto連携強化
- [ ] ai.gpt ↔ ai.ai連携機能
- [ ] 分散SNS ↔ ゲーム同期
### Phase 3: メタバース浸透
- [ ] VTuber配信機能統合
- [ ] Play-to-Work変換システム
- [ ] ai.game device prototype
## 将来的な連携構想
### システム間連携(現在は独立実装)
```
ai.gpt (自律送信) ←→ ai.ai (心理分析)
ai.card (iOS,Web,API) ←→ ai.verse (UEゲーム世界)
```
**共通基盤**: fastapi_mcp
**共通思想**: yui system現実の反映・唯一性担保
### データ改ざん防止戦略
- **短期**: MCP serverによる検証
- **中期**: OAuth 2.1 scope実装待ち
- **長期**: ブロックチェーン的整合性チェック
## AIコミュニケーション最適化
### プロジェクト要件定義テンプレート
```markdown
# [プロジェクト名] 要件定義
## 哲学的背景
- 存在子理論との関連:
- yui system適用範囲
- 現実反映の仕組み:
## 技術要件
- 使用技術fastapi_mcp統一
- atproto連携方法
- データ永続化方法:
## ユーザーストーリー
1. ユーザーが...すると
2. システムが...を実行し
3. 結果として...が実現される
## 成功指標
- 技術的:
- 哲学的(唯一性担保):
```
### Claude Code活用戦略
1. **小さく始める**: ai.gptのMCP機能拡張から
2. **段階的統合**: 各システムを個別に完成させてから統合
3. **哲学的一貫性**: 各実装でyui systemとの整合性を確認
4. **現実反映**: 実装がどう現実とゲームを繋ぐかを常に明記
## 開発上の留意点
### MCP Server設計指針
- 各AIgpt, card, ai, botは独立したMCPサーバー
- fastapi_mcp基盤で統一
- atproto DIDによる認証・認可
### 記憶・データ管理
- **ai.gpt**: 関係性の不可逆性重視
- **ai.card**: ユーザーデータ主権重視
- **ai.verse**: ゲーム世界の整合性重視
### 唯一性担保実装
- atproto accountとの1:1紐付け必須
- 改変不可能性をハッシュ・署名で保証
- 他システムでの再現不可能性を技術的に実現
## 継続的改善
- 各プロジェクトでこの設計書を参照
- 新機能追加時はyui systemとの整合性をチェック
- 他システムへの影響を事前評価
- Claude Code導入時の段階的移行計画
## ai.gpt深層設計思想
### 人格の不可逆性
- **関係性の破壊は修復不可能**: 現実の人間関係と同じ重み
- **記憶の選択的忘却**: 重要でない情報は忘れるが、コア記憶は永続
- **時間減衰**: すべてのパラメータは時間とともに自然減衰
### AI運勢システム
- 1-10のランダム値で日々の人格に変化
- 連続した幸運/不運による突破条件
- 環境要因としての人格形成
### 記憶の階層構造
1. **完全ログ**: すべての会話を記録
2. **AI要約**: 重要な部分を抽出して圧縮
3. **思想コア判定**: ユーザーの本質的な部分を特定
4. **選択的忘却**: 重要度の低い情報を段階的に削除
### 実装における重要な決定事項
- **言語統一**: Python (fastapi_mcp) で統一、CLIはclick/typer
- **データ形式**: JSON/SQLite選択式
- **認証**: atproto DIDによる唯一性担保
- **段階的実装**: まず会話→記憶→関係性→送信機能の順で実装
### 送信機能の段階的実装
- **Phase 1**: CLIでのprint出力現在
- **Phase 2**: atproto直接投稿
- **Phase 3**: ai.bot (Rust/seahorse) との連携
- **将来**: マルチチャネル対応SNS、Webhook等
## ai.gpt実装状況2025/01/06
### 完成した機能
- 階層的記憶システムMemoryManager
- 不可逆的関係性システムRelationshipTracker
- AI運勢システムFortuneSystem
- 統合人格システムPersona
- スケジューラー5種類のタスク
- MCP Server9種類のツール
- 設定管理(~/.config/aigpt/
- 全CLIコマンド実装
### 次の開発ポイント
- `ai_gpt/DEVELOPMENT_STATUS.md` を参照
- 自律送信: transmission.pyでatproto実装
- ai.bot連携: 新規bot_connector.py作成
- テスト: tests/ディレクトリ追加
## このprojectはai.log
このprojectは`ai.log`にあたります。
package, codeは`ailog`となります。
```sh
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.log
{
"type": "blog",
"text": "今はhugoでblogを作ってる。それをclaude codeでrustの静的ブログジェネレーターを作る。AI機能を付け加える。AI機能は具体的に記事の修正、情報の追加、lang:jaを自動翻訳してlang:en(page)を生成。アイが一言コメントするコメント欄の追加を行う。なお、コメント欄はatprotoと連携し、atprotoアカウントのoauthでログインして書き込める"
}
```
rustで静的ブログジェネレーターを作ります。参考になるのが`zola`です。
- https://github.com/getzola/zola
また、atprotoとの連携は`ai.card``atproto/oauth`の実装が参考になります。
- https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md
```json
{
"client_id": "https://example.com/client-metadata.json",
"client_name": "Example atproto Browser App",
"client_uri": "https://example.com",
"logo_uri": "https://example.com/logo.png",
"tos_uri": "https://example.com/tos",
"policy_uri": "https://example.com/policy",
"redirect_uris": ["https://example.com/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
```
```js
// package
import { Agent } from '@atproto/api'
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
async function main() {
const oauthClient = await BrowserOAuthClient.load({
clientId: '<YOUR_CLIENT_ID>',
handleResolver: 'https://bsky.social/',
})
// TO BE CONTINUED
}
document.addEventListener('DOMContentLoaded', main)
// client
const result = await oauthClient.init()
if (result) {
if ('state' in result) {
console.log('The user was just redirected back from the authorization page')
}
console.log(`The user is currently signed in as ${result.session.did}`)
}
const session = result?.session
// session
if (session) {
const agent = new Agent(session)
const fetchProfile = async () => {
const profile = await agent.getProfile({ actor: agent.did })
return profile.data
}
// Update the user interface
document.body.textContent = `Authenticated as ${agent.did}`
const profileBtn = document.createElement('button')
document.body.appendChild(profileBtn)
profileBtn.textContent = 'Fetch Profile'
profileBtn.onclick = async () => {
const profile = await fetchProfile()
outputPre.textContent = JSON.stringify(profile, null, 2)
}
const logoutBtn = document.createElement('button')
document.body.appendChild(logoutBtn)
logoutBtn.textContent = 'Logout'
logoutBtn.onclick = async () => {
await session.signOut()
window.location.reload()
}
const outputPre = document.createElement('pre')
document.body.appendChild(outputPre)
}
```
AIとの連携は`ai.gpt`をみてください。
- https://git.syui.ai/ai/gpt
`claude.md`があるので、`../gpt/claude.md`を読み込んでください。
### build deploy
主に`github-actions`, `cloudflare pages`を使用することを想定しています。
build, deploy, AIとの連携は記事をpushすると、自動で行われます。
```yml
// .github/workflows/gh-pages.yml
name: github pages
on:
push:
branches:
- main
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.139.2"
extended: true
- name: Build
env:
TZ: "Asia/Tokyo"
run: |
hugo version
TZ=Asia/Tokyo hugo
touch ./public/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
publish_branch: gh-pages
```
# footer
© syui

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)
}
}