add cargo
This commit is contained in:
		
							
								
								
									
										9
									
								
								.claude/settings.local.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.claude/settings.local.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| { | ||||
|   "permissions": { | ||||
|     "allow": [ | ||||
|       "Bash(cargo init:*)", | ||||
|       "Bash(cargo:*)" | ||||
|     ], | ||||
|     "deny": [] | ||||
|   } | ||||
| } | ||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| /target | ||||
| /Cargo.lock | ||||
| /public | ||||
| *.swp | ||||
| *.swo | ||||
| *~ | ||||
| .DS_Store | ||||
							
								
								
									
										28
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								Cargo.toml
									
									
									
									
									
										Normal 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" | ||||
							
								
								
									
										73
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										73
									
								
								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 | ||||
|   | ||||
							
								
								
									
										478
									
								
								claude.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										478
									
								
								claude.md
									
									
									
									
									
										Normal 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設計指針 | ||||
| - 各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: '<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
									
								
							
							
						
						
									
										22
									
								
								src/commands/build.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										21
									
								
								src/commands/clean.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										216
									
								
								src/commands/init.rs
									
									
									
									
									
										Normal 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>© 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
									
								
							
							
						
						
									
										5
									
								
								src/commands/mod.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										48
									
								
								src/commands/new.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										77
									
								
								src/commands/serve.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										63
									
								
								src/config.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										178
									
								
								src/generator.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										75
									
								
								src/main.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										138
									
								
								src/markdown.rs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										35
									
								
								src/template.rs
									
									
									
									
									
										Normal 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) | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user