diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..43dced9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo init:*)", + "Bash(cargo:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ae5be4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/Cargo.lock +/public +*.swp +*.swo +*~ +.DS_Store \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5edcc5e --- /dev/null +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/README.md b/README.md index e69de29..7305d65 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..9cddeef --- /dev/null +++ b/claude.md @@ -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設計指針 +- 各AI(gpt, 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 Server(9種類のツール) +- 設定管理(~/.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: '', + 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 diff --git a/src/commands/build.rs b/src/commands/build.rs new file mode 100644 index 0000000..985a2b1 --- /dev/null +++ b/src/commands/build.rs @@ -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(()) +} \ No newline at end of file diff --git a/src/commands/clean.rs b/src/commands/clean.rs new file mode 100644 index 0000000..35fa464 --- /dev/null +++ b/src/commands/clean.rs @@ -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(()) +} \ No newline at end of file diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..57fcb03 --- /dev/null +++ b/src/commands/init.rs @@ -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#" + + + + + {% block title %}{{ config.title }}{% endblock %} + + + +
+

{{ config.title }}

+

{{ config.description }}

+
+ +
+ {% block content %}{% endblock %} +
+ + + +"#; + + fs::write(path.join("templates/base.html"), base_template)?; + println!(" {} templates/base.html", "Created".cyan()); + + let index_template = r#"{% extends "base.html" %} + +{% block content %} +

Recent Posts

+ +{% 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 %} +
+

{{ post.title }}

+ +
+ {{ post.content | safe }} +
+
+{% 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(()) +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..7308f56 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod init; +pub mod build; +pub mod new; +pub mod serve; +pub mod clean; \ No newline at end of file diff --git a/src/commands/new.rs b/src/commands/new.rs new file mode 100644 index 0000000..d36509d --- /dev/null +++ b/src/commands/new.rs @@ -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(()) +} \ No newline at end of file diff --git a/src/commands/serve.rs b/src/commands/serve.rs new file mode 100644 index 0000000..e8c69c8 --- /dev/null +++ b/src/commands/serve.rs @@ -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"

404 - Not Found

".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)> { + 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)) +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..32ecf0b --- /dev/null +++ b/src/config.rs @@ -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, +} + +#[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 { + 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, + }), + } + } +} \ No newline at end of file diff --git a/src/generator.rs b/src/generator.rs new file mode 100644 index 0000000..7d95e85 --- /dev/null +++ b/src/generator.rs @@ -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 { + 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> { + 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 { + 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, +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..32d5c1c --- /dev/null +++ b/src/main.rs @@ -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(()) +} \ No newline at end of file diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..7126e34 --- /dev/null +++ b/src/markdown.rs @@ -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)> { + let matter = Matter::::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 { + 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 { + 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("
");
+
+        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("
"); + output + } +} \ No newline at end of file diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..17bf582 --- /dev/null +++ b/src/template.rs @@ -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 { + 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 { + 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 { + let output = self.tera.render(template, context)?; + Ok(output) + } + + pub fn render_with_context(&self, template: &str, context: &Context) -> Result { + let output = self.tera.render(template, context)?; + Ok(output) + } +} \ No newline at end of file