add cargo
This commit is contained in:
parent
e191cb376c
commit
02dd69840d
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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user