import * as fs from 'fs'
import * as path from 'path'
import { marked, Renderer } from 'marked'
import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts'
import { escapeHtml } from '../src/lib/utils.ts'
// 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 `
${highlighted}
`
}
marked.setOptions({
breaks: true,
gfm: true,
renderer,
})
}
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 {
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 {
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 {
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
}
async function listRecordsFromApi(did: string, collection: string, pdsUrl: string): Promise {
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 }> }
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 {
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 {
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 - auto-detect from NSID
// app.bsky.feed.post → bsky.app
// chat.bsky.* → bsky.app (exception: grouped with bsky.app)
// com.example.thing → example.com
// ai.syui.log.post → syui.ai
function getServiceDomain(collection: string): string | null {
// Special case: chat.bsky.* goes to bsky.app
if (collection.startsWith('chat.bsky.')) {
return 'bsky.app'
}
const parts = collection.split('.')
if (parts.length >= 2) {
return `${parts[1]}.${parts[0]}`
}
return null
}
function getServiceDomains(collections: string[]): string[] {
const domains = new Set()
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 {
const faviconDir = getFaviconDir(did)
if (!fs.existsSync(faviconDir)) {
fs.mkdirSync(faviconDir, { recursive: true })
}
const faviconUrls: Record = {
'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 }, isStatic: boolean = true): string {
const staticAttr = isStatic ? ' data-static="true"' : ''
return `
${escapeHtml(title)} - ${escapeHtml(config.title)}
${config.color ? `` : ''}
${content}
`
}
function generateProfileHtml(profile: Profile): string {
const avatar = profile.avatar
? `
`
: ''
return `
${avatar}
${escapeHtml(profile.displayName || profile.handle)}
@${escapeHtml(profile.handle)}
${profile.description ? `
${escapeHtml(profile.description)}
` : ''}
`
}
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 generateHeaderHtml(handle: string): string {
return `
`
}
function generateServicesHtml(did: string, handle: string, collections: string[]): string {
const serviceMap = new Map()
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 `
${domain}
`
}).join('')
return `${items}
`
}
function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string {
return `
`
}
function generatePostListHtml(posts: BlogPost[]): string {
if (posts.length === 0) {
return 'No posts yet
'
}
const items = posts.map(post => {
const rkey = post.uri.split('/').pop()
return `
${escapeHtml(post.title)}
${formatDate(post.createdAt)}
`
}).join('')
return ``
}
// Map network to app URL for discussion links
function getAppUrl(network: string): string {
if (network === 'syu.is') {
return 'https://syu.is'
}
return 'https://bsky.app'
}
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
const content = marked.parse(post.content) as string
// Use siteUrl from config, or construct from handle
const baseSiteUrl = siteUrl || `https://${handle}`
const postUrl = `${baseSiteUrl}/post/${rkey}/`
const appUrl = getAppUrl(network)
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(postUrl)}`
return `
${content}
`
}
function generateFooterHtml(handle: string): string {
const username = handle.split('.')[0] || handle
return `
`
}
function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[]): string {
return `
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)}
${generateServicesHtml(profile.did, config.handle, collections)}
${generatePostListHtml(posts)}
${generateFooterHtml(config.handle)}
`
}
function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[]): string {
return `
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile)}
${generateServicesHtml(profile.did, config.handle, collections)}
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
${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()
// Always fetch from API to get latest posts, merge with local
const localDid = findLocalDid()
let did: string
let profile: Profile
let posts: BlogPost[]
// Resolve DID and get profile
did = await resolveHandle(config.handle, network.bsky)
profile = await getProfile(did, network.bsky)
const pdsUrl = await getPdsEndpoint(did, network.plc)
console.log(`Profile: ${profile.displayName || profile.handle}`)
// Fetch posts from API
const apiPosts = await listRecordsFromApi(did, config.collection, pdsUrl)
console.log(`Found ${apiPosts.length} posts from API`)
// Load local posts if they exist
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
console.log(`Found ${localPosts.length} posts from local`)
// Merge: API is the source of truth
// - If post exists in API: always use API (has latest edits)
// - If post exists in local only: keep if not deleted (for posts beyond API limit)
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
// Local posts that don't exist in API (older posts beyond 100 limit)
// Note: these might be deleted posts, so we keep them cautiously
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
posts = [...apiPosts, ...oldLocalPosts].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
console.log(`Total ${posts.length} posts (${apiPosts.length} from API + ${oldLocalPosts.length} old local)`)
// 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 })
}
// Generate /post/index.html as SPA for new post form
const postFormContent = `
`
const postFormHtml = generateHtml('New Post', postFormContent, config, assets, false)
fs.writeFileSync(path.join(postsDir, 'index.html'), postFormHtml)
console.log('Generated: /post/index.html')
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 fallback page for dynamic routes (no data-static attribute)
const spaContent = `
`
const spaHtml = generateHtml('App', spaContent, config, assets, false)
fs.writeFileSync(path.join(distDir, 'app.html'), spaHtml)
console.log('Generated: /app.html')
// Generate _redirects for Cloudflare Pages (SPA routes)
const redirects = `/app / 301
/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)
})
}