Files
log/scripts/generate.ts
2026-01-16 12:55:57 +09:00

881 lines
32 KiB
TypeScript

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 `<pre><code class="hljs">${highlighted}</code></pre>`
}
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<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(),
translations: r.value.translations as BlogPost['translations'] || undefined,
})).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 - 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<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 }, isStatic: boolean = true): string {
const staticAttr = isStatic ? ' data-static="true"' : ''
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="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/assets/${assets.css}">
${config.color ? `<style>:root { --btn-color: ${config.color}; }</style>` : ''}
</head>
<body>
<div id="app"${staticAttr}>
${content}
</div>
<script type="module" src="/assets/${assets.js}"></script>
</body>
</html>`
}
function generateProfileHtml(profile: Profile, webUrl?: string): string {
const avatar = profile.avatar
? `<img src="${profile.avatar}" class="profile-avatar" alt="">`
: ''
const profileLink = webUrl ? `${webUrl}/profile/${profile.did}` : null
const handleHtml = profileLink
? `<a href="${profileLink}" class="profile-handle-link" target="_blank" rel="noopener">@${escapeHtml(profile.handle)}</a>`
: `@${escapeHtml(profile.handle)}`
return `
<div class="profile">
${avatar}
<div class="profile-info">
<div class="profile-name">${escapeHtml(profile.displayName || profile.handle)}</div>
<div class="profile-handle">${handleHtml}</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 generateHeaderHtml(handle: string): string {
return `
<div class="header">
<form class="header-form" id="header-form">
<input type="text" class="header-input" id="header-input" placeholder="handle (e.g., syui.ai)" value="${escapeHtml(handle)}">
<button type="submit" class="header-btn at-btn" title="Browse">@</button>
<button type="button" class="header-btn login-btn" id="login-btn" title="Login">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10 17 15 12 10 7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
</button>
</form>
</div>
`
}
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>
<button type="button" class="tab" id="browser-tab" data-handle="${handle}">Browser</button>
</div>
`
}
function generateLangSelectorHtml(): string {
const langIcon = `<svg viewBox="0 0 640 640" width="20" height="20" fill="currentColor"><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></svg>`
return `
<div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language">
${langIcon}
</button>
<div class="lang-dropdown" id="lang-dropdown">
<div class="lang-option" data-lang="ja">
<span class="lang-name">日本語</span>
<span class="lang-check">✓</span>
</div>
<div class="lang-option selected" data-lang="en">
<span class="lang-name">English</span>
<span class="lang-check">✓</span>
</div>
</div>
</div>
`
}
function generatePostListHtml(posts: BlogPost[]): string {
if (posts.length === 0) {
return '<p class="no-posts">No posts yet</p>'
}
// Build translations data for titles
const titleTranslations: Record<string, { original: string; translated: string }> = {}
posts.forEach(post => {
const rkey = post.uri.split('/').pop()
if (rkey && post.translations?.en?.title) {
titleTranslations[rkey] = {
original: post.title,
translated: post.translations.en.title
}
}
})
const hasTranslations = Object.keys(titleTranslations).length > 0
const translationScript = hasTranslations
? `<script id="title-translations" type="application/json">${JSON.stringify(titleTranslations)}</script>`
: ''
const items = posts.map(post => {
const rkey = post.uri.split('/').pop()
// Default to English title if available
const displayTitle = post.translations?.en?.title || post.title
return `
<li class="post-item">
<a href="/post/${rkey}/" class="post-link">
<span class="post-title" data-rkey="${rkey}">${escapeHtml(displayTitle)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span>
</a>
</li>
`
}).join('')
return `
<div class="content-header">${generateLangSelectorHtml()}</div>
${translationScript}
<ul class="post-list">${items}</ul>
`
}
// 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 originalContent = marked.parse(post.content) as string
// Check for English translation
const hasTranslation = post.translations?.en?.content
const translatedContent = hasTranslation ? marked.parse(post.translations!.en.content) as string : ''
// Default to English if translation exists
const displayContent = hasTranslation ? translatedContent : originalContent
// Use siteUrl from config, or construct from handle
const baseSiteUrl = siteUrl || `https://${handle}`
const postUrl = `${baseSiteUrl}/post/${rkey}/`
const appUrl = getAppUrl(network)
// Convert to search-friendly format (domain/post/rkey_prefix without https://)
// Keep total length around 20 chars to avoid URL truncation in posts
const MAX_SEARCH_LENGTH = 20
const urlObj = new URL(postUrl)
const pathParts = urlObj.pathname.split('/').filter(Boolean)
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
const remainingLength = MAX_SEARCH_LENGTH - basePath.length
const rkeyPrefix = remainingLength > 0 ? rkey.slice(0, remainingLength) : ''
const searchQuery = basePath + rkeyPrefix
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
// Store translation data in script tag for JS switching
const hasTranslatedTitle = !!post.translations?.en?.title
const translationScript = hasTranslation
? `<script id="translation-data" type="application/json">${JSON.stringify({
original: originalContent,
translated: translatedContent,
originalTitle: post.title,
translatedTitle: post.translations?.en?.title || post.title
})}</script>`
: ''
// Default to English title if available
const displayTitle = post.translations?.en?.title || post.title
return `
<div class="content-header">${generateLangSelectorHtml()}</div>
${translationScript}
<article class="post-detail">
<header class="post-header">
<h1 class="post-title" id="post-detail-title">${escapeHtml(displayTitle)}</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">${displayContent}</div>
</article>
<div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2.546 20.2A1.5 1.5 0 0 0 4 22h.5l2.83-.892A9.96 9.96 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2z"/>
</svg>
Discuss on Bluesky
</a>
<div id="discussion-posts" class="discussion-posts" data-post-url="${escapeHtml(postUrl)}" data-app-url="${escapeHtml(appUrl)}"></div>
</div>
`
}
interface FooterLink {
name: string
url: string
icon?: string
}
function loadLinks(): FooterLink[] {
const linksPath = path.join(process.cwd(), 'public/links.json')
if (!fs.existsSync(linksPath)) return []
try {
const data = JSON.parse(fs.readFileSync(linksPath, 'utf-8'))
return data.links || []
} catch {
return []
}
}
// Built-in SVG icons for common services
const BUILTIN_ICONS: Record<string, string> = {
bluesky: `<svg viewBox="0 0 600 530" fill="currentColor"><path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.72 40.255-67.24 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/></svg>`,
github: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>`,
ai: `<span class="icon-ai"></span>`,
git: `<span class="icon-git"></span>`,
}
function generateFooterLinksHtml(links: FooterLink[]): string {
if (links.length === 0) return ''
const items = links.map(link => {
let iconHtml = ''
if (link.icon && BUILTIN_ICONS[link.icon]) {
iconHtml = BUILTIN_ICONS[link.icon]
} else {
// Fallback to favicon
const domain = new URL(link.url).hostname
iconHtml = `<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=32" alt="" class="footer-link-favicon">`
}
return `
<a href="${escapeHtml(link.url)}" class="footer-link-item" title="${escapeHtml(link.name)}" target="_blank" rel="noopener">
${iconHtml}
</a>
`
}).join('')
return `
<div class="footer-links">
${items}
</div>
`
}
function generateFooterHtml(handle: string, links: FooterLink[]): string {
const username = handle.split('.')[0] || handle
return `
${generateFooterLinksHtml(links)}
<footer class="site-footer">
<p>&copy; ${username}</p>
</footer>
`
}
function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[], links: FooterLink[], webUrl?: string): string {
return `
<header id="header">${generateHeaderHtml(config.handle)}</header>
<main>
<section id="profile">
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile, webUrl)}
${generateServicesHtml(profile.did, config.handle, collections)}
</section>
<section id="content">
${generatePostListHtml(posts)}
</section>
</main>
${generateFooterHtml(config.handle, links)}
`
}
function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[], links: FooterLink[], webUrl?: string): string {
return `
<header id="header">${generateHeaderHtml(config.handle)}</header>
<main>
<section id="profile">
${generateTabsHtml('blog', config.handle)}
${generateProfileHtml(profile, webUrl)}
${generateServicesHtml(profile.did, config.handle, collections)}
</section>
<section id="content">
${generatePostDetailHtml(post, config.handle, config.collection, config.network, config.siteUrl)}
</section>
</main>
${generateFooterHtml(config.handle, links)}
`
}
// 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 for content, but local has translations
// - If post exists in both: use API data but merge translations from local
// - If post exists in API only: use API
// - If post exists in local only: keep (for posts beyond API limit)
const localPostMap = new Map<string, BlogPost>()
for (const post of localPosts) {
const rkey = post.uri.split('/').pop()
if (rkey) localPostMap.set(rkey, post)
}
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
// Merge API posts with local translations
const mergedApiPosts = apiPosts.map(apiPost => {
const rkey = apiPost.uri.split('/').pop()
const localPost = rkey ? localPostMap.get(rkey) : undefined
if (localPost?.translations && !apiPost.translations) {
return { ...apiPost, translations: localPost.translations }
}
return apiPost
})
// Local posts that don't exist in API (older posts beyond 100 limit)
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
posts = [...mergedApiPosts, ...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)
// Load footer links
const links = loadLinks()
// Get web URL for profile links
const webUrl = network.web
// Generate index page
const indexContent = generateIndexPageContent(profile, posts, config, collections, links, webUrl)
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 = `
<header id="header"></header>
<main>
<section id="profile"></section>
<section id="content"></section>
</main>
<footer id="footer"></footer>
`
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, links, webUrl)
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 = `
<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, 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', 'links.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 pkg directory (icomoon fonts, etc.)
const pkgSrc = path.join(process.cwd(), 'public/pkg')
const pkgDest = path.join(distDir, 'pkg')
if (fs.existsSync(pkgSrc)) {
fs.cpSync(pkgSrc, pkgDest, { 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)
})
}