diff --git a/.github/workflows/cf-pages.yml b/.github/workflows/cf-pages.yml index 2e2661c..80d3d23 100644 --- a/.github/workflows/cf-pages.yml +++ b/.github/workflows/cf-pages.yml @@ -23,11 +23,8 @@ jobs: - name: Install dependencies run: npm install - - name: Fetch content from ATProto - run: npm run fetch - - - name: Generate static site - run: npm run generate + - name: Build content from ATProto + run: npm run build - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 diff --git a/Cargo.toml b/Cargo.toml index 79929ea..bc37255 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ailog" -version = "0.2.0" +version = "0.0.1" edition = "2021" description = "ATProto blog CLI" authors = ["syui"] diff --git a/config.json b/config.json deleted file mode 100644 index a5358af..0000000 --- a/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": "syui.ai", - "handle": "syui.syui.ai", - "collection": "ai.syui.log.post", - "network": "syu.is", - "color": "#EF454A", - "siteUrl": "https://syui.ai" -} diff --git a/index.html b/index.html index 140ef9a..e172358 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - ailog + syui.ai diff --git a/lexicon/ai.syui.log.post.json b/lexicons/ai.syui.log.post.json similarity index 100% rename from lexicon/ai.syui.log.post.json rename to lexicons/ai.syui.log.post.json diff --git a/network.json b/network.json deleted file mode 100644 index 62de6a3..0000000 --- a/network.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "bsky.social": { - "plc": "https://plc.directory", - "bsky": "https://public.api.bsky.app", - "web": "https://bsky.app" - }, - "syu.is": { - "plc": "https://plc.syu.is", - "bsky": "https://bsky.syu.is", - "web": "https://syu.is" - } -} diff --git a/package.json b/package.json index 5fd75e0..95821ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ailog", - "version": "0.2.0", + "version": "0.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/readme.md b/readme.md index a2fb7bf..4fa9092 100644 --- a/readme.md +++ b/readme.md @@ -1,346 +1,7 @@ # ailog -ATProto-based blog platform built on at-browser. - -## Concept - -**Data lives in ATProto, not on this server.** - -This is not a traditional blog generator. It's a **viewer (client)** for ATProto records. - -``` -Traditional blog: - Server DB ← article data ← user - -ATProto blog: - User's PDS ← article data (ai.syui.log.post) - ↓ - at-browser (this site) → displays records +```sh +$ vim public/config.json +$ npm run build ``` -## Architecture - -``` -┌─────────────────────────────────────────┐ -│ at-browser │ -│ (ATProto record viewer/editor) │ -├─────────────────────────────────────────┤ -│ │ -│ / → admin (config.json) │ -│ /@alice → user page │ -│ /@bob.bsky → user page │ -│ │ -└─────────────────────────────────────────┘ -``` - -## Roles - -| Role | Path | Data Source | -|------|------|-------------| -| **admin** | `/` (root) | local + remote | -| **user** | `/@handle` | remote only | - -### Admin (Site Owner) - -- Defined in `config.json` -- Has root (`/`) access -- Can reference **local files** (static assets, custom styles) -- Can reference **remote** (ATProto records) - -### User (Any ATProto User) - -- Accessed via `/@handle` path -- **Remote only** (ATProto records from their PDS) -- No registration required -- Anyone with an ATProto account can be displayed - -## Features - -### 1. at-browser (Core) - -- Search by handle/DID -- Browse PDS collections -- Navigate ATProto records - -### 2. ai.syui.log.post View - -- Markdown rendering -- Syntax highlighting -- Blog-style display - -### 3. OAuth - -- Login with ATProto -- Post to ai.syui.log.post collection - -## Use Cases - -### Personal Blog - -```json -// config.json -{ - "did": "did:plc:xxxxx", - "handle": "syui.syui.ai" -} -``` - -- Deploy to `syui.ai` -- Root shows your profile + posts -- You are the admin (local + remote) -- Others can view via `/@handle` - -### Blog Service - -```json -// config.json -{ - "admin": "service.example.com", - "handle": null -} -``` - -- Deploy to `blog.example.com` -- Root shows landing/search -- All users via `/@handle` (remote only) -- Platform for any ATProto user - -## Data Flow - -``` -┌──────────────┐ ┌──────────────┐ -│ User's PDS │────→│ at-browser │ -│ (ATProto) │←────│ (this site) │ -└──────────────┘ └──────────────┘ - ↑ │ - │ ↓ - ai.syui.log.post ┌──────────┐ - collection │ Display │ - │ - Profile│ - │ - Posts │ - └──────────┘ -``` - -## Local = Remote (Same Format) - -**Critical design principle: local files use the exact same format as ATProto API responses.** - -This allows the same code to handle both data sources. - -### Remote (ATProto API) - -```bash -curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.post" -``` - -```json -{ - "records": [ - { - "uri": "at://did:plc:xxx/ai.syui.log.post/3xxx", - "cid": "bafyrei...", - "value": { - "title": "Hello World", - "content": "# Hello\n\nThis is my post.", - "createdAt": "2025-01-01T00:00:00Z" - } - } - ] -} -``` - -### Local (Static File) - -``` -content/ -└── did:plc:xxx/ - ├── describe.json # describeRepo (special) - ├── app.bsky.actor.profile/ - │ └── self.json # {collection}/{rkey}.json - └── ai.syui.log.post/ - └── 3xxx.json # {collection}/{rkey}.json -``` - -```json -// content/did:plc:xxx/ai.syui.log.post/3xxx.json -{ - "uri": "at://did:plc:xxx/ai.syui.log.post/3xxx", - "cid": "bafyrei...", - "value": { - "title": "Hello World", - "content": "# Hello\n\nThis is my post.", - "createdAt": "2025-01-01T00:00:00Z" - } -} -``` - -### ATProto API Reference - -| API | Path | Description | -|-----|------|-------------| -| getRecord | `/xrpc/com.atproto.repo.getRecord` | Get single record | -| listRecords | `/xrpc/com.atproto.repo.listRecords` | List records in collection | -| describeRepo | `/xrpc/com.atproto.repo.describeRepo` | Get repo info + collections list | - -See: [com.atproto.repo.describeRepo](https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo) - -### Resolution Strategy - -``` -at-browser - │ - ├── admin (config.json user) - │ ├── 1. Check local: /content/{did}/{collection}/{rkey}.json - │ └── 2. Fallback to remote: PDS API - │ - └── user (/@handle) - └── remote only: PDS API -``` - -### Why Same Format? - -- **One codebase**: No branching logic for local vs remote -- **Easy testing**: Copy API response to local file -- **Offline support**: Admin can work with local files -- **Migration**: Local → Remote (just POST to PDS) - -## Config - -### config.json - -```json -{ - "did": "did:plc:xxxxx", - "handle": "syui.syui.ai", - "pds": "syu.is", - "collection": "ai.syui.log.post" -} -``` - -## Tech Stack - -- **CLI**: Rust (ailog) -- **Frontend**: Vite + TypeScript -- **ATProto**: @atproto/api -- **OAuth**: @atproto/oauth-client-browser -- **Markdown**: marked + highlight.js - -## CLI (ailog) - -### Install - -```bash -cargo build --release -cp target/release/ailog ~/.local/bin/ -``` - -### Commands - -```bash -# Login to ATProto PDS -ailog login -p [-s ] - -# Post a record -ailog post -c [-r ] - -# Get records from collection -ailog get -c [-l ] - -# Delete a record -ailog delete -c -r - -# Sync PDS data to local content directory -ailog sync [-o ] - -# Generate lexicon Rust code from ATProto lexicons -ailog gen [-i ] [-o ] -``` - -### Example - -```bash -# Login -ailog login syui.syui.ai -p "app-password" -s syu.is - -# Post -echo '{"title":"Hello","content":"World","createdAt":"2025-01-01T00:00:00Z"}' > post.json -ailog post post.json -c ai.syui.log.post - -# Sync to local -ailog sync -o content -``` - -### Project Structure - -``` -src/ -├── main.rs -├── commands/ -│ ├── mod.rs -│ ├── auth.rs # login, refresh session -│ ├── token.rs # token management -│ ├── post.rs # post, get, delete, sync -│ └── gen.rs # lexicon code generation -└── lexicons/ - └── mod.rs # auto-generated from ATProto lexicons -``` - -### Lexicon Generation - -Generate Rust endpoint definitions from ATProto lexicon JSON files: - -```bash -# Clone atproto repo (if not exists) -git clone https://github.com/bluesky-social/atproto repos/atproto - -# Generate lexicons -ailog gen -i ./repos/atproto/lexicons -o ./src/lexicons - -# Rebuild -cargo build -``` - -## Collection Schema - -### ai.syui.log.post - -```json -{ - "title": "Post Title", - "content": "Markdown content...", - "createdAt": "2025-01-01T00:00:00Z" -} -``` - -## Assets - -### PNG to SVG Conversion (Vector Trace) - -Convert PNG images to true vector SVG using vtracer (Rust): - -```bash -# Install vtracer -cargo install vtracer - -# Convert PNG to SVG (color mode) -vtracer --input input.png --output output.svg --colormode color - -# Convert PNG to SVG (black and white) -vtracer --input input.png --output output.svg -``` - -**Options:** -- `--colormode color` : Preserve colors (recommended for icons) -- `--colormode binary` : Black and white only -- `--filter_speckle 4` : Remove small artifacts -- `--corner_threshold 60` : Adjust corner detection - -**Alternative tools:** -- potrace: `potrace input.pbm -s -o output.svg` (B&W only, requires PBM input) -- Inkscape CLI: `inkscape input.png --export-type=svg` (embeds image, no trace) - -**Note:** Inkscape's CLI `--export-type=svg` only embeds the PNG, it does not trace. For true vectorization, use vtracer or potrace. - -## License - -MIT diff --git a/src/commands/gen.rs b/src/commands/gen.rs index a4c7f71..6f59d23 100644 --- a/src/commands/gen.rs +++ b/src/commands/gen.rs @@ -140,6 +140,7 @@ fn generate_rust_code(namespaces: &BTreeMap>) -> Strin code.push_str("//! Auto-generated from ATProto lexicons\n"); code.push_str("//! Run `ailog gen` to regenerate\n"); code.push_str("//! Do not edit manually\n\n"); + code.push_str("#![allow(dead_code)]\n\n"); // Endpoint struct code.push_str("#[derive(Debug, Clone, Copy)]\n"); diff --git a/src/commands/post.rs b/src/commands/post.rs index 378bd4f..9c20841 100644 --- a/src/commands/post.rs +++ b/src/commands/post.rs @@ -32,6 +32,7 @@ struct PutRecordResponse { struct ListRecordsResponse { records: Vec, #[serde(default)] + #[allow(dead_code)] cursor: Option, } @@ -234,7 +235,7 @@ struct DescribeRepoResponse { /// Sync PDS data to local content directory pub async fn sync_to_local(output: &str) -> Result<()> { - let config_content = fs::read_to_string("config.json") + let config_content = fs::read_to_string("public/config.json") .context("config.json not found")?; let config: Config = serde_json::from_str(&config_content)?; diff --git a/src/lexicons/mod.rs b/src/lexicons/mod.rs index f54e4a6..838e8cc 100644 --- a/src/lexicons/mod.rs +++ b/src/lexicons/mod.rs @@ -2,6 +2,8 @@ //! Run `ailog gen` to regenerate //! Do not edit manually +#![allow(dead_code)] + #[derive(Debug, Clone, Copy)] pub struct Endpoint { pub nsid: &'static str, diff --git a/src/web/components/discussion.ts b/src/web/components/discussion.ts index 9b553f5..e1aacdc 100644 --- a/src/web/components/discussion.ts +++ b/src/web/components/discussion.ts @@ -1,4 +1,4 @@ -import { searchPostsForUrl, type SearchPost } from '../lib/api' +import { searchPostsForUrl, getCurrentNetwork, type SearchPost } from '../lib/api' const DISCUSSION_POST_LIMIT = 10 @@ -29,16 +29,20 @@ function getPostUrl(uri: string, appUrl: string): string { } export function renderDiscussion(postUrl: string, appUrl: string = 'https://bsky.app'): string { - // Build search URL (truncate for search limit) + // Build search URL with host/@username only let searchQuery = postUrl try { const urlObj = new URL(postUrl) const pathParts = urlObj.pathname.split('/').filter(Boolean) - const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/' - const rkey = pathParts[1] || '' - const remainingLength = 20 - basePath.length - const rkeyPrefix = remainingLength > 0 ? rkey.slice(0, remainingLength) : '' - searchQuery = basePath + rkeyPrefix + // pathParts[0] = @username.domain (e.g., @syui.syui.ai) + // Extract just @username + if (pathParts[0]?.startsWith('@')) { + const handlePart = pathParts[0].slice(1) // remove @ + const username = handlePart.split('.')[0] // get first part before . + searchQuery = `${urlObj.host}/@${username}` + } else { + searchQuery = urlObj.host + } } catch { // Keep original } @@ -62,7 +66,9 @@ export async function loadDiscussionPosts(container: HTMLElement, postUrl: strin const postsContainer = container.querySelector('#discussion-posts') as HTMLElement if (!postsContainer) return - const dataAppUrl = postsContainer.dataset.appUrl || appUrl + // Get appUrl from network config (overrides default) + const network = await getCurrentNetwork() + const dataAppUrl = network.web || postsContainer.dataset.appUrl || appUrl postsContainer.innerHTML = '
Loading comments...
' diff --git a/src/web/components/posts.ts b/src/web/components/posts.ts index 3135143..a9d69ac 100644 --- a/src/web/components/posts.ts +++ b/src/web/components/posts.ts @@ -50,7 +50,7 @@ export function renderPostDetail( const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}` // Build post URL for discussion search - const postUrl = siteUrl ? `${siteUrl}/${rkey}` : `${window.location.origin}/@${handle}/${rkey}` + const postUrl = siteUrl ? `${siteUrl}/@${handle}/${rkey}` : `${window.location.origin}/@${handle}/${rkey}` const editBtn = isOwner ? `` : '' diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index 0eec51e..4839eb6 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -245,38 +245,41 @@ export async function getRecord(did: string, collection: string, rkey: string): // Constants for search const SEARCH_TIMEOUT_MS = 5000 -const MAX_SEARCH_LENGTH = 20 + +// Get current network config +export async function getCurrentNetwork(): Promise<{ plc: string; bsky: string; web: string }> { + const config = await getConfig() + const networks = await getNetworks() + const networkKey = config.network || 'bsky.social' + const network = networks[networkKey] + return { + plc: network?.plc || 'https://plc.directory', + bsky: network?.bsky || 'https://public.api.bsky.app', + web: network?.web || 'https://bsky.app' + } +} + +// Get search endpoint for current network +async function getSearchEndpoint(): Promise { + const network = await getCurrentNetwork() + return network.bsky +} // Search posts that link to a URL export async function searchPostsForUrl(url: string): Promise { - // Use public.api.bsky.app for search - const endpoint = 'https://public.api.bsky.app' + // Use current network's endpoint for search + const endpoint = await getSearchEndpoint() // Extract search-friendly patterns from URL + // Note: Search API doesn't index paths well, so search by domain and filter client-side const searchQueries: string[] = [] try { const urlObj = new URL(url) - const pathWithDomain = urlObj.host + urlObj.pathname.replace(/\/$/, '') - - // Limit length for search - if (pathWithDomain.length <= MAX_SEARCH_LENGTH) { - searchQueries.push(pathWithDomain) - } else { - // Truncate to max length - searchQueries.push(pathWithDomain.slice(0, MAX_SEARCH_LENGTH)) - } - - // Also try shorter path - const pathParts = urlObj.pathname.split('/').filter(Boolean) - if (pathParts.length >= 1) { - const shortPath = urlObj.host + '/' + pathParts[0] - if (shortPath.length <= MAX_SEARCH_LENGTH) { - searchQueries.push(shortPath) - } - } + // Search by domain only (paths with / don't return results) + searchQueries.push(urlObj.host) } catch { - searchQueries.push(url.slice(0, MAX_SEARCH_LENGTH)) + searchQueries.push(url) } const allPosts: SearchPost[] = []