fix post generate

This commit is contained in:
2026-01-15 19:46:01 +09:00
parent 162072d980
commit 7e5980a12c
12 changed files with 1194 additions and 117 deletions

View File

@@ -21,10 +21,13 @@ jobs:
uses: actions/setup-node@v4
- name: Install dependencies
run: npm i
run: npm install
- name: Build
run: npm run build
- name: Fetch content from ATProto
run: npm run fetch
- name: Generate static site
run: npm run generate
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist
node_modules
package-lock.json
repos
content/

View File

@@ -6,13 +6,18 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"fetch": "tsx scripts/generate.ts fetch",
"generate": "npm run build && tsx scripts/generate.ts"
},
"dependencies": {
"@atproto/api": "^0.15.8",
"@atproto/oauth-client-browser": "^0.3.39"
"@atproto/oauth-client-browser": "^0.3.39",
"highlight.js": "^11.11.1",
"marked": "^17.0.1"
},
"devDependencies": {
"tsx": "^4.21.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}

35
readme.md Normal file
View File

@@ -0,0 +1,35 @@
# ailog
## config
```sh
$ ls public/
config.json
networks.json
client-metadata.json
```
## preview
```sh
$ npm run dev
```
## content generate
speed up by using local cache
```sh
$ npm run fetch # API download -> content/${did}/...
$ npm run generate # content html -> dist/
$ npm run preview # dist/ preview server
```
```sh
content/
└── did:plc:uqzpqmrjnptsxezjx4xuh2mn/
├── profile.json
└── ai.syui.log.post/
└── 3mch5zca4nj2h.json
```

699
scripts/generate.ts Normal file
View File

@@ -0,0 +1,699 @@
import * as fs from 'fs'
import * as path from 'path'
import { marked, Renderer } from 'marked'
// Types
interface AppConfig {
title: string
handle: string
collection: string
network: string
color?: string
}
interface Networks {
[key: string]: {
plc: string
bsky: string
}
}
interface Profile {
did: string
handle: string
displayName?: string
description?: string
avatar?: string
}
interface BlogPost {
uri: string
cid: string
title: string
content: string
createdAt: string
}
// Highlight.js for syntax highlighting (core + common languages only)
let hljs: typeof import('highlight.js/lib/core').default
async function loadHighlightJs() {
const core = await import('highlight.js/lib/core')
hljs = core.default
const [js, ts, bash, json, yaml, md, css, xml, py, rust, go] = await Promise.all([
import('highlight.js/lib/languages/javascript'),
import('highlight.js/lib/languages/typescript'),
import('highlight.js/lib/languages/bash'),
import('highlight.js/lib/languages/json'),
import('highlight.js/lib/languages/yaml'),
import('highlight.js/lib/languages/markdown'),
import('highlight.js/lib/languages/css'),
import('highlight.js/lib/languages/xml'),
import('highlight.js/lib/languages/python'),
import('highlight.js/lib/languages/rust'),
import('highlight.js/lib/languages/go'),
])
hljs.registerLanguage('javascript', js.default)
hljs.registerLanguage('js', js.default)
hljs.registerLanguage('typescript', ts.default)
hljs.registerLanguage('ts', ts.default)
hljs.registerLanguage('bash', bash.default)
hljs.registerLanguage('sh', bash.default)
hljs.registerLanguage('json', json.default)
hljs.registerLanguage('yaml', yaml.default)
hljs.registerLanguage('yml', yaml.default)
hljs.registerLanguage('markdown', md.default)
hljs.registerLanguage('md', md.default)
hljs.registerLanguage('css', css.default)
hljs.registerLanguage('html', xml.default)
hljs.registerLanguage('xml', xml.default)
hljs.registerLanguage('python', py.default)
hljs.registerLanguage('py', py.default)
hljs.registerLanguage('rust', rust.default)
hljs.registerLanguage('go', go.default)
}
// Markdown renderer
function setupMarked() {
const renderer = new Renderer()
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
let highlighted: string
if (lang && hljs.getLanguage(lang)) {
try {
highlighted = hljs.highlight(text, { language: lang }).value
} catch {
highlighted = escapeHtml(text)
}
} else {
highlighted = escapeHtml(text)
}
return `<pre><code class="hljs">${highlighted}</code></pre>`
}
marked.setOptions({
breaks: true,
gfm: true,
renderer,
})
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
// API functions
async function resolveHandle(handle: string, bskyUrl: string): Promise<string> {
const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`)
if (!res.ok) throw new Error('Failed to resolve handle')
const data = await res.json() as { did: string }
return data.did
}
async function getPdsEndpoint(did: string, plcUrl: string): Promise<string> {
const res = await fetch(`${plcUrl}/${did}`)
if (!res.ok) throw new Error('Failed to resolve DID')
const doc = await res.json() as { service: Array<{ type: string; serviceEndpoint: string }> }
const pds = doc.service.find(s => s.type === 'AtprotoPersonalDataServer')
if (!pds) throw new Error('PDS not found in DID document')
return pds.serviceEndpoint
}
async function getProfile(did: string, bskyUrl: string): Promise<Profile> {
const res = await fetch(`${bskyUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
if (!res.ok) throw new Error('Failed to get profile')
return res.json() as Promise<Profile>
}
async function listRecordsFromApi(did: string, collection: string, pdsUrl: string): Promise<BlogPost[]> {
const res = await fetch(
`${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100`
)
if (!res.ok) return []
const data = await res.json() as { records: Array<{ uri: string; cid: string; value: Record<string, unknown> }> }
return data.records.map(r => ({
uri: r.uri,
cid: r.cid,
title: r.value.title as string || 'Untitled',
content: r.value.content as string || '',
createdAt: r.value.createdAt as string || new Date().toISOString(),
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
async function describeRepo(did: string, pdsUrl: string): Promise<string[]> {
const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.describeRepo?repo=${did}`)
if (!res.ok) return []
const data = await res.json() as { collections: string[] }
return data.collections || []
}
// Content file functions
function getContentDir(): string {
return path.join(process.cwd(), 'content')
}
function getCollectionDir(did: string, collection: string): string {
return path.join(getContentDir(), did, collection)
}
function getProfilePath(did: string): string {
return path.join(getContentDir(), did, 'profile.json')
}
function savePostToFile(post: BlogPost, did: string, collection: string): string {
const collectionDir = getCollectionDir(did, collection)
if (!fs.existsSync(collectionDir)) {
fs.mkdirSync(collectionDir, { recursive: true })
}
const rkey = post.uri.split('/').pop()!
const jsonPath = path.join(collectionDir, `${rkey}.json`)
fs.writeFileSync(jsonPath, JSON.stringify(post, null, 2))
return rkey
}
function saveProfile(profile: Profile): void {
const profileDir = path.join(getContentDir(), profile.did)
if (!fs.existsSync(profileDir)) {
fs.mkdirSync(profileDir, { recursive: true })
}
fs.writeFileSync(path.join(profileDir, 'profile.json'), JSON.stringify(profile, null, 2))
}
function loadPostsFromFiles(did: string, collection: string): BlogPost[] {
const collectionDir = getCollectionDir(did, collection)
if (!fs.existsSync(collectionDir)) {
return []
}
const files = fs.readdirSync(collectionDir)
const jsonFiles = files.filter(f => f.endsWith('.json'))
const posts: BlogPost[] = []
for (const jsonFile of jsonFiles) {
const jsonPath = path.join(collectionDir, jsonFile)
const post: BlogPost = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
posts.push(post)
}
return posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
function loadProfile(did: string): Profile | null {
const profilePath = getProfilePath(did)
if (!fs.existsSync(profilePath)) return null
return JSON.parse(fs.readFileSync(profilePath, 'utf-8'))
}
function hasLocalContent(did: string, collection: string): boolean {
const collectionDir = getCollectionDir(did, collection)
if (!fs.existsSync(collectionDir)) return false
const files = fs.readdirSync(collectionDir)
return files.some(f => f.endsWith('.json'))
}
function findLocalDid(): string | null {
const contentDir = getContentDir()
if (!fs.existsSync(contentDir)) return null
const dirs = fs.readdirSync(contentDir)
const didDir = dirs.find(d => d.startsWith('did:'))
return didDir || null
}
// Favicon functions
function getFaviconDir(did: string): string {
return path.join(getContentDir(), did, 'favicons')
}
async function downloadFavicon(url: string, filepath: string): Promise<boolean> {
try {
const res = await fetch(url)
if (!res.ok) return false
const buffer = await res.arrayBuffer()
fs.writeFileSync(filepath, Buffer.from(buffer))
return true
} catch {
return false
}
}
// Service domain mapping
function getServiceDomain(collection: string): string | null {
const prefixMap: Record<string, string> = {
'app.bsky': 'bsky.app',
'chat.bsky': 'bsky.app',
'ai.syui': 'syui.ai',
}
for (const [prefix, domain] of Object.entries(prefixMap)) {
if (collection.startsWith(prefix)) {
return domain
}
}
return null
}
function getServiceDomains(collections: string[]): string[] {
const domains = new Set<string>()
for (const col of collections) {
const domain = getServiceDomain(col)
if (domain) domains.add(domain)
}
return Array.from(domains)
}
async function downloadFavicons(did: string, domains: string[]): Promise<void> {
const faviconDir = getFaviconDir(did)
if (!fs.existsSync(faviconDir)) {
fs.mkdirSync(faviconDir, { recursive: true })
}
const faviconUrls: Record<string, string> = {
'bsky.app': 'https://bsky.app/static/favicon-32x32.png',
'syui.ai': 'https://syui.ai/favicon.png',
}
for (const domain of domains) {
const url = faviconUrls[domain]
if (!url) continue
const filepath = path.join(faviconDir, `${domain}.png`)
if (!fs.existsSync(filepath)) {
const ok = await downloadFavicon(url, filepath)
if (ok) {
console.log(`Downloaded: ${domain}.png`)
}
}
}
}
function getLocalFaviconPath(did: string, domain: string): string | null {
const faviconDir = getFaviconDir(did)
const filename = `${domain}.png`
const filepath = path.join(faviconDir, filename)
if (fs.existsSync(filepath)) {
return `/favicons/${filename}`
}
return null
}
// HTML Templates
function getAssetFilenames(): { css: string; js: string } {
const assetsDir = path.join(process.cwd(), 'dist/assets')
if (!fs.existsSync(assetsDir)) {
return { css: 'index.css', js: 'index.js' }
}
const files = fs.readdirSync(assetsDir)
const css = files.find(f => f.endsWith('.css')) || 'index.css'
const js = files.find(f => f.endsWith('.js')) || 'index.js'
return { css, js }
}
function generateHtml(title: string, content: string, config: AppConfig, assets: { css: string; js: string }, includeJs: boolean = false): string {
const scriptTag = includeJs ? `<script type="module" src="/assets/${assets.js}"></script>` : ''
return `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)} - ${escapeHtml(config.title)}</title>
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/${assets.css}">
${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''}
</head>
<body>
<div id="app">
${content}
</div>
${scriptTag}
</body>
</html>`
}
function generateProfileHtml(profile: Profile): string {
const avatar = profile.avatar
? `<img src="${profile.avatar}" class="profile-avatar" alt="">`
: ''
return `
<div class="profile">
${avatar}
<div class="profile-info">
<div class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</div>
<div class="profile-handle">@${escapeHtml(profile.handle)}</div>
${profile.description ? `<div class="profile-desc">${escapeHtml(profile.description)}</div>` : ''}
</div>
</div>
`
}
function loadCollections(did: string): string[] {
const collectionsPath = path.join(getContentDir(), did, 'collections.json')
if (!fs.existsSync(collectionsPath)) return []
return JSON.parse(fs.readFileSync(collectionsPath, 'utf-8'))
}
function generateServicesHtml(did: string, handle: string, collections: string[]): string {
const serviceMap = new Map<string, string[]>()
for (const col of collections) {
const domain = getServiceDomain(col)
if (domain) {
if (!serviceMap.has(domain)) {
serviceMap.set(domain, [])
}
serviceMap.get(domain)!.push(col)
}
}
if (serviceMap.size === 0) return ''
const items = Array.from(serviceMap.entries()).map(([domain, cols]) => {
// Use local favicon if exists
const localFavicon = getLocalFaviconPath(did, domain)
const faviconSrc = localFavicon || `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
return `
<a href="/at/${handle}/${domain}" class="service-item" title="${cols.join(', ')}">
<img src="${faviconSrc}" class="service-favicon" alt="" onerror="this.style.display='none'">
<span class="service-name">${domain}</span>
</a>
`
}).join('')
return `<div class="services">${items}</div>`
}
function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string {
return `
<div class="mode-tabs">
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}">Blog</a>
<a href="/at/${handle}" class="tab ${activeTab === 'browser' ? 'active' : ''}">Browser</a>
</div>
`
}
function generatePostListHtml(posts: BlogPost[]): string {
if (posts.length === 0) {
return '<p class="no-posts">No posts yet</p>'
}
const items = posts.map(post => {
const rkey = post.uri.split('/').pop()
return `
<li class="post-item">
<a href="/post/${rkey}/" class="post-link">
<span class="post-title">${escapeHtml(post.title)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span>
</a>
</li>
`
}).join('')
return `<ul class="post-list">${items}</ul>`
}
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string): string {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
const content = marked.parse(post.content) as string
return `
<article class="post-detail">
<header class="post-header">
<h1 class="post-title">${escapeHtml(post.title)}</h1>
<div class="post-meta">
<time class="post-date">${formatDate(post.createdAt)}</time>
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
</header>
<div class="post-content">${content}</div>
</article>
`
}
function generateFooterHtml(handle: string): string {
const username = handle.split('.')[0] || handle
return `
<footer class="site-footer">
<p>&copy; ${username}</p>
</footer>
`
}
function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[]): string {
return `
<header id="header"></header>
<main>
<section id="profile">
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)}
${generateServicesHtml(profile.did, config.handle, collections)}
</section>
<section id="content">
${generatePostListHtml(posts)}
</section>
</main>
${generateFooterHtml(config.handle)}
`
}
function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[]): string {
return `
<header id="header"></header>
<main>
<section id="profile">
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)}
${generateServicesHtml(profile.did, config.handle, collections)}
</section>
<section id="content">
${generatePostDetailHtml(post, config.handle, config.collection)}
</section>
</main>
${generateFooterHtml(config.handle)}
`
}
// Fetch command - downloads posts from ATProto and saves to content/
async function fetchPosts() {
console.log('Fetching posts from ATProto...')
const configPath = path.join(process.cwd(), 'public/config.json')
const config: AppConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const networksPath = path.join(process.cwd(), 'public/networks.json')
const networks: Networks = JSON.parse(fs.readFileSync(networksPath, 'utf-8'))
const network = networks[config.network]
if (!network) {
throw new Error(`Network ${config.network} not found`)
}
const did = await resolveHandle(config.handle, network.bsky)
console.log(`DID: ${did}`)
const pdsUrl = await getPdsEndpoint(did, network.plc)
console.log(`PDS: ${pdsUrl}`)
// Save profile
const profile = await getProfile(did, network.bsky)
saveProfile(profile)
console.log(`Saved: content/${did}/profile.json`)
// Get and save collections
const collections = await describeRepo(did, pdsUrl)
const collectionsPath = path.join(getContentDir(), did, 'collections.json')
fs.writeFileSync(collectionsPath, JSON.stringify(collections, null, 2))
console.log(`Saved: content/${did}/collections.json`)
// Download favicons for each service domain
const domains = getServiceDomains(collections)
await downloadFavicons(did, domains)
// Save posts
const posts = await listRecordsFromApi(did, config.collection, pdsUrl)
console.log(`Found ${posts.length} posts`)
for (const post of posts) {
const rkey = savePostToFile(post, did, config.collection)
console.log(`Saved: content/${did}/${config.collection}/${rkey}.json`)
}
console.log('\nFetch complete!')
}
// Generate command - builds HTML from content/ files (or API if no local files)
async function generate() {
console.log('Starting static generation...')
// Load config
const configPath = path.join(process.cwd(), 'public/config.json')
const config: AppConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
console.log(`Config loaded: ${config.handle}`)
// Load networks
const networksPath = path.join(process.cwd(), 'public/networks.json')
const networks: Networks = JSON.parse(fs.readFileSync(networksPath, 'utf-8'))
const network = networks[config.network]
if (!network) {
throw new Error(`Network ${config.network} not found in networks.json`)
}
// Setup markdown
await loadHighlightJs()
setupMarked()
// Try to load from local content first (no API calls)
const localDid = findLocalDid()
let did: string
let profile: Profile
let posts: BlogPost[]
if (localDid) {
did = localDid
const localProfile = loadProfile(did)
if (localProfile && hasLocalContent(did, config.collection)) {
console.log('Loading from local content (no API calls)...')
profile = localProfile
posts = loadPostsFromFiles(did, config.collection)
console.log(`Profile: ${profile.displayName || profile.handle}`)
console.log(`Found ${posts.length} posts`)
} else {
// Local DID exists but missing data, fetch from API
console.log('Fetching from API...')
did = await resolveHandle(config.handle, network.bsky)
profile = await getProfile(did, network.bsky)
const pdsUrl = await getPdsEndpoint(did, network.plc)
posts = await listRecordsFromApi(did, config.collection, pdsUrl)
console.log(`Profile: ${profile.displayName || profile.handle}`)
console.log(`Found ${posts.length} posts from API`)
}
} else {
// No local content, fetch everything from API
console.log('No local content, fetching from API...')
did = await resolveHandle(config.handle, network.bsky)
profile = await getProfile(did, network.bsky)
const pdsUrl = await getPdsEndpoint(did, network.plc)
posts = await listRecordsFromApi(did, config.collection, pdsUrl)
console.log(`Profile: ${profile.displayName || profile.handle}`)
console.log(`Found ${posts.length} posts from API`)
}
// Create output directory
const distDir = path.join(process.cwd(), 'dist')
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true })
}
// Get asset filenames (with hashes)
const assets = getAssetFilenames()
// Load collections for services display
const collections = loadCollections(did)
// Generate index page
const indexContent = generateIndexPageContent(profile, posts, config, collections)
const indexHtml = generateHtml(config.title, indexContent, config, assets)
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml)
console.log('Generated: /index.html')
// Generate post pages
const postsDir = path.join(distDir, 'post')
if (!fs.existsSync(postsDir)) {
fs.mkdirSync(postsDir, { recursive: true })
}
for (const post of posts) {
const rkey = post.uri.split('/').pop()
if (!rkey) continue
const postDir = path.join(postsDir, rkey)
if (!fs.existsSync(postDir)) {
fs.mkdirSync(postDir, { recursive: true })
}
const postContent = generatePostPageContent(profile, post, config, collections)
const postHtml = generateHtml(post.title, postContent, config, assets)
fs.writeFileSync(path.join(postDir, 'index.html'), postHtml)
console.log(`Generated: /post/${rkey}/index.html`)
}
// Generate SPA page for browser/oauth (with JS)
const spaContent = `
<header id="header"></header>
<main>
<section id="profile"></section>
<nav id="tabs"></nav>
<section id="content"></section>
</main>
<footer id="footer"></footer>
`
const spaHtml = generateHtml('App', spaContent, config, assets, true) // include JS
fs.writeFileSync(path.join(distDir, 'app.html'), spaHtml)
console.log('Generated: /app.html (SPA with JS)')
// Generate _redirects for Cloudflare Pages
const redirects = `/at/* /app.html 200
/new /app.html 200
/oauth/* /app.html 200
`
fs.writeFileSync(path.join(distDir, '_redirects'), redirects)
console.log('Generated: /_redirects')
// Copy static files
const filesToCopy = ['favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json']
for (const file of filesToCopy) {
const src = path.join(process.cwd(), 'public', file)
const dest = path.join(distDir, file)
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest)
}
}
// Copy .well-known directory
const wellKnownSrc = path.join(process.cwd(), 'public/.well-known')
const wellKnownDest = path.join(distDir, '.well-known')
if (fs.existsSync(wellKnownSrc)) {
fs.cpSync(wellKnownSrc, wellKnownDest, { recursive: true })
}
// Copy favicons from content
const faviconSrc = getFaviconDir(did)
const faviconDest = path.join(distDir, 'favicons')
if (fs.existsSync(faviconSrc)) {
if (!fs.existsSync(faviconDest)) {
fs.mkdirSync(faviconDest, { recursive: true })
}
fs.cpSync(faviconSrc, faviconDest, { recursive: true })
}
console.log('\nStatic generation complete!')
console.log(`Output: ${distDir}`)
}
// CLI
const command = process.argv[2]
if (command === 'fetch') {
fetchPosts().catch(err => {
console.error('Fetch failed:', err)
process.exit(1)
})
} else {
generate().catch(err => {
console.error('Generation failed:', err)
process.exit(1)
})
}

View File

@@ -47,7 +47,7 @@ async function renderServices(did: string, handle: string): Promise<string> {
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
return `
<li class="service-list-item">
<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}" class="service-list-link">
<a href="/at/${handle}/${domain}" class="service-list-link">
<img src="${info.favicon}" class="service-list-favicon" alt="" onerror="this.style.display='none'">
<span class="service-list-name">${info.name}</span>
<span class="service-list-count">${info.count}</span>
@@ -84,7 +84,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str
const items = filtered.map(col => {
return `
<li class="collection-item">
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}" class="collection-link">
<a href="/at/${handle}/${col}" class="collection-link">
<span class="collection-nsid">${col}</span>
</a>
</li>
@@ -111,7 +111,7 @@ async function renderRecordList(did: string, handle: string, collection: string)
const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey
return `
<li class="record-item">
<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}&rkey=${rkey}" class="record-link">
<a href="/at/${handle}/${collection}/${rkey}" class="record-link">
<span class="record-rkey">${rkey}</span>
<span class="record-preview">${preview}</span>
</a>
@@ -178,16 +178,16 @@ export async function mountAtBrowser(
let nav = ''
if (collection && rkey) {
nav = `<a href="?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}" class="back-link">← Back</a>`
nav = `<a href="/at/${handle}/${collection}" class="back-link">← Back</a>`
content = await renderRecordDetail(did, handle, collection, rkey, canDelete)
} else if (collection) {
// Get service from collection for back link
const info = getServiceInfo(collection)
const backService = info ? info.domain : ''
nav = `<a href="?mode=browser&handle=${handle}&service=${encodeURIComponent(backService)}" class="back-link">← ${info?.name || 'Back'}</a>`
nav = `<a href="/at/${handle}/${backService}" class="back-link">← ${info?.name || 'Back'}</a>`
content = await renderRecordList(did, handle, collection)
} else if (service) {
nav = `<a href="?mode=browser&handle=${handle}" class="back-link">← Services</a>`
nav = `<a href="/at/${handle}" class="back-link">← Services</a>`
content = await renderCollections(did, handle, service)
} else {
content = await renderServices(did, handle)
@@ -213,7 +213,7 @@ export async function mountAtBrowser(
btn.textContent = 'Deleting...'
await deleteRecord(col, rk)
// Go back to collection
window.location.href = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}`
window.location.href = `/at/${handle}/${col}`
} catch (err) {
alert('Delete failed: ' + err)
btn.disabled = false

View File

@@ -1,5 +1,6 @@
import type { BlogPost } from '../types.js'
import { putRecord } from '../lib/auth.js'
import { renderMarkdown } from '../lib/markdown.js'
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
@@ -28,7 +29,7 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
const rkey = post.uri.split('/').pop()
return `
<li class="post-item">
<a href="?rkey=${rkey}" class="post-link">
<a href="/post/${rkey}" class="post-link">
<span class="post-title">${escapeHtml(post.title)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span>
</a>
@@ -41,7 +42,7 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false): void {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}&rkey=${rkey}`
const jsonUrl = `/at/${handle}/${collection}/${rkey}`
const editBtn = canEdit ? `<button class="edit-btn" id="edit-btn">edit</button>` : ''
@@ -55,7 +56,7 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle:
${editBtn}
</div>
</header>
<div class="post-content" id="post-content">${escapeHtml(post.content)}</div>
<div class="post-content" id="post-content">${renderMarkdown(post.content)}</div>
</article>
<div class="edit-form-container" id="edit-form-container" style="display: none;">

View File

@@ -23,7 +23,7 @@ export async function renderServices(handle: string): Promise<string> {
}
const items = Array.from(serviceMap.entries()).map(([domain, info]) => {
const url = `?mode=browser&handle=${handle}&service=${encodeURIComponent(domain)}`
const url = `/at/${handle}/${domain}`
return `
<a href="${url}" class="service-item" title="${info.collections.join(', ')}">

71
src/lib/markdown.ts Normal file
View File

@@ -0,0 +1,71 @@
import { marked, Renderer } from 'marked'
import hljs from 'highlight.js/lib/core'
// Import only common languages
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import markdown from 'highlight.js/lib/languages/markdown'
import css from 'highlight.js/lib/languages/css'
import xml from 'highlight.js/lib/languages/xml'
import python from 'highlight.js/lib/languages/python'
import rust from 'highlight.js/lib/languages/rust'
import go from 'highlight.js/lib/languages/go'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('js', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('ts', typescript)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sh', bash)
hljs.registerLanguage('shell', bash)
hljs.registerLanguage('json', json)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('yml', yaml)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('md', markdown)
hljs.registerLanguage('css', css)
hljs.registerLanguage('html', xml)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('python', python)
hljs.registerLanguage('py', python)
hljs.registerLanguage('rust', rust)
hljs.registerLanguage('rs', rust)
hljs.registerLanguage('go', go)
// Custom renderer with syntax highlighting
const renderer = new Renderer()
renderer.code = function({ text, lang }: { text: string; lang?: string }) {
let highlighted: string
if (lang && hljs.getLanguage(lang)) {
try {
highlighted = hljs.highlight(text, { language: lang }).value
} catch {
highlighted = escapeHtml(text)
}
} else {
// No auto-detect, just escape
highlighted = escapeHtml(text)
}
return `<pre><code class="hljs">${highlighted}</code></pre>`
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
marked.setOptions({
breaks: true,
gfm: true,
renderer,
})
export function renderMarkdown(content: string): string {
return marked.parse(content) as string
}

86
src/lib/router.ts Normal file
View File

@@ -0,0 +1,86 @@
export interface Route {
type: 'blog' | 'post' | 'browser-services' | 'browser-collections' | 'browser-record' | 'new'
handle?: string
collection?: string
rkey?: string
service?: string
}
export function parseRoute(pathname: string): Route {
const parts = pathname.split('/').filter(Boolean)
// / - Blog top
if (parts.length === 0) {
return { type: 'blog' }
}
// /new - New post form
if (parts[0] === 'new') {
return { type: 'new' }
}
// /post/${rkey} - Post detail
if (parts[0] === 'post' && parts[1]) {
return { type: 'post', rkey: parts[1] }
}
// /at/${handle} - Browser services
// /at/${handle}/${service-or-collection} - Browser collections or records
// /at/${handle}/${collection}/${rkey} - Browser record detail
if (parts[0] === 'at' && parts[1]) {
const handle = parts[1]
if (!parts[2]) {
// /at/${handle}
return { type: 'browser-services', handle }
}
if (!parts[3]) {
// /at/${handle}/${service-or-collection}
// If it looks like a domain (2 parts), treat as service
// Otherwise treat as collection NSID (3+ parts)
const segment = parts[2]
if (segment.split('.').length <= 2) {
// Likely a service domain like "bsky.app"
return { type: 'browser-collections', handle, service: segment }
} else {
// Likely a collection NSID like "app.bsky.feed.post"
// Show record list for this collection
return { type: 'browser-record', handle, collection: segment }
}
}
// /at/${handle}/${collection}/${rkey}
return { type: 'browser-record', handle, collection: parts[2], rkey: parts[3] }
}
// Fallback to blog
return { type: 'blog' }
}
export function buildPath(route: Route): string {
switch (route.type) {
case 'blog':
return '/'
case 'new':
return '/new'
case 'post':
return `/post/${route.rkey}`
case 'browser-services':
return `/at/${route.handle}`
case 'browser-collections':
return `/at/${route.handle}/${route.service}`
case 'browser-record':
if (route.rkey) {
return `/at/${route.handle}/${route.collection}/${route.rkey}`
}
return `/at/${route.handle}/${route.collection}`
default:
return '/'
}
}
export function navigate(path: string): void {
window.history.pushState({}, '', path)
window.dispatchEvent(new PopStateEvent('popstate'))
}

View File

@@ -6,9 +6,11 @@ import { mountPostList, mountPostDetail } from './components/posts.js'
import { mountHeader } from './components/browser.js'
import { mountAtBrowser } from './components/atbrowser.js'
import { mountPostForm } from './components/postform.js'
import { parseRoute, type Route } from './lib/router.js'
import type { AppConfig, Networks } from './types.js'
let authSession: AuthSession | null = null
let config: AppConfig
async function loadConfig(): Promise<AppConfig> {
const res = await fetch('/config.json')
@@ -30,25 +32,138 @@ function renderFooter(handle: string): string {
`
}
function renderTabs(handle: string, mode: string | null, isLoggedIn: boolean): string {
const blogActive = !mode || mode === 'blog' ? 'active' : ''
const browserActive = mode === 'browser' ? 'active' : ''
const postActive = mode === 'post' ? 'active' : ''
function renderTabs(route: Route, isLoggedIn: boolean): string {
const isBlog = route.type === 'blog' || route.type === 'post'
const isBrowser = route.type.startsWith('browser')
const isNew = route.type === 'new'
let tabs = `
<a href="?handle=${handle}" class="tab ${blogActive}">Blog</a>
<a href="?mode=browser&handle=${handle}" class="tab ${browserActive}">Browser</a>
<a href="/" class="tab ${isBlog ? 'active' : ''}">Blog</a>
<a href="/at/${config.handle}" class="tab ${isBrowser ? 'active' : ''}">Browser</a>
`
if (isLoggedIn) {
tabs += `<a href="?mode=post&handle=${handle}" class="tab ${postActive}">Post</a>`
tabs += `<a href="/new" class="tab ${isNew ? 'active' : ''}">Post</a>`
}
return `<div class="mode-tabs">${tabs}</div>`
}
async function render(): Promise<void> {
const route = parseRoute(window.location.pathname)
const profileEl = document.getElementById('profile')
const contentEl = document.getElementById('content')
const headerEl = document.getElementById('header')
const footerEl = document.getElementById('footer')
if (!profileEl || !contentEl || !headerEl) return
// Footer
if (footerEl) {
footerEl.innerHTML = renderFooter(config.handle)
}
const isLoggedIn = !!authSession
const handle = route.handle || config.handle
// Header with login
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => {
window.location.href = `/at/${newHandle}`
},
onLogin: async () => {
const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle
try {
await login(inputHandle)
} catch (err) {
console.error('Login error:', err)
alert('Login failed: ' + err)
}
},
onLogout: async () => {
await logout()
window.location.reload()
}
})
// Route handling
switch (route.type) {
case 'new':
if (isLoggedIn) {
profileEl.innerHTML = renderTabs(route, isLoggedIn)
mountPostForm(contentEl, config.collection, () => {
window.location.href = '/'
})
} else {
window.location.href = '/'
}
break
case 'browser-services':
case 'browser-collections':
case 'browser-record':
profileEl.innerHTML = renderTabs(route, isLoggedIn)
const loginDid = authSession?.did || null
await mountAtBrowser(
contentEl,
route.handle || config.handle,
route.collection || null,
route.rkey || null,
route.service || null,
loginDid
)
break
case 'post':
try {
const profile = await getProfile(config.handle)
profileEl.innerHTML = renderTabs(route, isLoggedIn)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile)
const servicesHtml = await renderServices(config.handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const post = await getRecord(profile.did, config.collection, route.rkey!)
if (post) {
const canEdit = isLoggedIn && authSession?.did === profile.did
mountPostDetail(contentEl, post, config.handle, config.collection, canEdit)
} else {
contentEl.innerHTML = '<p>Post not found</p>'
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
break
case 'blog':
default:
try {
const profile = await getProfile(config.handle)
profileEl.innerHTML = renderTabs(route, isLoggedIn)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile)
const servicesHtml = await renderServices(config.handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
const posts = await listRecords(profile.did, config.collection)
mountPostList(contentEl, posts)
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
break
}
}
async function init(): Promise<void> {
const [config, networks] = await Promise.all([loadConfig(), loadNetworks()])
const [configData, networks] = await Promise.all([loadConfig(), loadNetworks()])
config = configData
// Set page title
document.title = config.title || 'ailog'
@@ -70,102 +185,14 @@ async function init(): Promise<void> {
if (callbackSession) {
authSession = callbackSession
} else {
// Try to restore existing session
authSession = await restoreSession()
}
const params = new URLSearchParams(window.location.search)
const mode = params.get('mode')
const rkey = params.get('rkey')
const collection = params.get('collection')
const service = params.get('service')
const handle = params.get('handle') || config.handle
// Initial render
await render()
const profileEl = document.getElementById('profile')
const contentEl = document.getElementById('content')
const headerEl = document.getElementById('header')
const footerEl = document.getElementById('footer')
if (!profileEl || !contentEl || !headerEl) return
// Footer
if (footerEl) {
footerEl.innerHTML = renderFooter(config.handle)
}
const isLoggedIn = !!authSession
// Header with login
mountHeader(headerEl, handle, isLoggedIn, authSession?.handle, {
onBrowse: (newHandle) => {
const currentMode = params.get('mode')
if (currentMode === 'browser') {
window.location.href = `?mode=browser&handle=${newHandle}`
} else {
window.location.href = `?handle=${newHandle}`
}
},
onLogin: async () => {
const inputHandle = (document.getElementById('header-input') as HTMLInputElement)?.value || handle
try {
await login(inputHandle)
} catch (err) {
console.error('Login error:', err)
alert('Login failed: ' + err)
}
},
onLogout: async () => {
await logout()
window.location.reload()
}
})
// Post mode (requires login)
if (mode === 'post' && isLoggedIn) {
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
mountPostForm(contentEl, config.collection, () => {
window.location.href = `?handle=${handle}`
})
return
}
// AT Browser mode
if (mode === 'browser') {
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
const loginDid = authSession?.did || null
await mountAtBrowser(contentEl, handle, collection, rkey, service, loginDid)
return
}
// Blog mode (default)
try {
const profile = await getProfile(handle)
profileEl.innerHTML = renderTabs(handle, mode, isLoggedIn)
const profileContentEl = document.createElement('div')
profileEl.appendChild(profileContentEl)
mountProfile(profileContentEl, profile)
// Add services
const servicesHtml = await renderServices(handle)
profileContentEl.insertAdjacentHTML('beforeend', servicesHtml)
if (rkey) {
const post = await getRecord(profile.did, config.collection, rkey)
if (post) {
const canEdit = isLoggedIn && authSession?.did === profile.did
mountPostDetail(contentEl, post, handle, config.collection, canEdit)
} else {
contentEl.innerHTML = '<p>Post not found</p>'
}
} else {
const posts = await listRecords(profile.did, config.collection)
mountPostList(contentEl, posts)
}
} catch (err) {
console.error(err)
contentEl.innerHTML = `<p class="error">Failed to load: ${err}</p>`
}
// Handle browser navigation
window.addEventListener('popstate', () => render())
}
init()

View File

@@ -429,9 +429,139 @@ body {
.post-content {
font-size: 16px;
line-height: 1.8;
white-space: pre-wrap;
}
/* Markdown Styles */
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4,
.post-content h5,
.post-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
.post-content h1 { font-size: 1.75em; }
.post-content h2 { font-size: 1.5em; }
.post-content h3 { font-size: 1.25em; }
.post-content h4 { font-size: 1.1em; }
.post-content p {
margin-bottom: 1em;
}
.post-content ul,
.post-content ol {
margin-bottom: 1em;
padding-left: 1.5em;
}
.post-content li {
margin-bottom: 0.25em;
}
.post-content a {
color: var(--btn-color);
text-decoration: none;
}
.post-content a:hover {
text-decoration: underline;
}
.post-content blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #ddd;
background: #f9f9f9;
color: #666;
}
.post-content code {
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.9em;
padding: 0.15em 0.4em;
background: #f0f0f0;
border-radius: 4px;
}
.post-content pre {
margin: 1em 0;
padding: 1em;
background: #1e1e1e;
border-radius: 8px;
overflow-x: auto;
}
.post-content pre code {
display: block;
padding: 0;
background: transparent;
color: #d4d4d4;
font-size: 14px;
line-height: 1.5;
}
.post-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.post-content hr {
margin: 2em 0;
border: none;
border-top: 1px solid #eee;
}
.post-content table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
}
.post-content th,
.post-content td {
padding: 0.5em;
border: 1px solid #ddd;
text-align: left;
}
.post-content th {
background: #f5f5f5;
font-weight: 600;
}
/* Highlight.js Theme Overrides */
.hljs-keyword,
.hljs-selector-tag,
.hljs-built_in,
.hljs-name,
.hljs-tag { color: #569cd6; }
.hljs-string,
.hljs-title,
.hljs-section,
.hljs-attribute,
.hljs-literal,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-addition { color: #ce9178; }
.hljs-comment,
.hljs-quote,
.hljs-deletion,
.hljs-meta { color: #6a9955; }
.hljs-number,
.hljs-regexp,
.hljs-symbol,
.hljs-variable,
.hljs-link { color: #b5cea8; }
.hljs-function { color: #dcdcaa; }
.hljs-attr { color: #9cdcfe; }
.post-footer {
margin-top: 32px;
padding-top: 16px;
@@ -776,4 +906,23 @@ body {
.delete-btn:hover {
background: #c82333;
}
/* Dark mode markdown */
.post-content blockquote {
border-color: #444;
background: #1a1a1a;
color: #aaa;
}
.post-content code {
background: #2a2a2a;
}
.post-content th {
background: #2a2a2a;
}
.post-content th,
.post-content td {
border-color: #444;
}
.post-content hr {
border-color: #333;
}
}