cleanup
This commit is contained in:
7
.github/workflows/cf-pages.yml
vendored
7
.github/workflows/cf-pages.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ailog"
|
||||
version = "0.2.0"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
description = "ATProto blog CLI"
|
||||
authors = ["syui"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ailog</title>
|
||||
<title>syui.ai</title>
|
||||
<link rel="stylesheet" href="/src/styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
12
network.json
12
network.json
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ailog",
|
||||
"version": "0.2.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
345
readme.md
345
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 <handle> -p <password> [-s <server>]
|
||||
|
||||
# Post a record
|
||||
ailog post <file.json> -c <collection> [-r <rkey>]
|
||||
|
||||
# Get records from collection
|
||||
ailog get -c <collection> [-l <limit>]
|
||||
|
||||
# Delete a record
|
||||
ailog delete -c <collection> -r <rkey>
|
||||
|
||||
# Sync PDS data to local content directory
|
||||
ailog sync [-o <output>]
|
||||
|
||||
# Generate lexicon Rust code from ATProto lexicons
|
||||
ailog gen [-i <input>] [-o <output>]
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -140,6 +140,7 @@ fn generate_rust_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> 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");
|
||||
|
||||
@@ -32,6 +32,7 @@ struct PutRecordResponse {
|
||||
struct ListRecordsResponse {
|
||||
records: Vec<Record>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = '<div class="loading-small">Loading comments...</div>'
|
||||
|
||||
|
||||
@@ -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 ? `<button type="button" class="post-edit-btn" id="post-edit-btn">Edit</button>` : ''
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
const network = await getCurrentNetwork()
|
||||
return network.bsky
|
||||
}
|
||||
|
||||
// Search posts that link to a URL
|
||||
export async function searchPostsForUrl(url: string): Promise<SearchPost[]> {
|
||||
// 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[] = []
|
||||
|
||||
Reference in New Issue
Block a user