886 lines
30 KiB
TypeScript
886 lines
30 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, formatDate } from '../src/lib/utils.ts'
|
|
import { LANG_ICON, BUILTIN_ICONS } from '../src/lib/icons.ts'
|
|
import { MAX_SEARCH_LENGTH } from '../src/lib/constants.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,
|
|
})
|
|
}
|
|
|
|
// 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, { redirect: 'follow' })
|
|
if (!res.ok) return false
|
|
const buffer = await res.arrayBuffer()
|
|
if (buffer.byteLength === 0) return false
|
|
fs.writeFileSync(filepath, Buffer.from(buffer))
|
|
return true
|
|
} catch (err) {
|
|
console.error(`Failed to download ${url}:`, err)
|
|
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
|
|
}
|
|
|
|
// Common service domains to always download favicons for
|
|
const COMMON_SERVICE_DOMAINS = [
|
|
'bsky.app',
|
|
'syui.ai',
|
|
'atproto.com',
|
|
'whtwnd.com',
|
|
'frontpage.fyi',
|
|
'pinksea.art',
|
|
'linkat.blue',
|
|
'tangled.sh',
|
|
'leaflet.pub',
|
|
]
|
|
|
|
function getServiceDomains(collections: string[]): string[] {
|
|
const domains = new Set<string>(COMMON_SERVICE_DOMAINS)
|
|
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 })
|
|
}
|
|
|
|
// Known favicon URLs (prefer official sources over Google)
|
|
// Others will use Google's favicon API as fallback
|
|
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 filepath = path.join(faviconDir, `${domain}.png`)
|
|
if (fs.existsSync(filepath)) continue
|
|
|
|
// Try known URL first, then fallback to Google's favicon API
|
|
const url = faviconUrls[domain] || `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
|
|
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 {
|
|
return `
|
|
<div class="lang-selector" id="lang-selector">
|
|
<button type="button" class="lang-btn" id="lang-btn" title="Language">
|
|
${LANG_ICON}
|
|
</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://)
|
|
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 []
|
|
}
|
|
}
|
|
|
|
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>© ${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)
|
|
// Static files (index.html, post/*/index.html) are served automatically
|
|
// Dynamic routes are rewritten to app.html
|
|
const redirects = `/oauth/* /app.html 200
|
|
/at/* /app.html 200
|
|
/new /app.html 200
|
|
/app /app.html 200
|
|
`
|
|
fs.writeFileSync(path.join(distDir, '_redirects'), redirects)
|
|
console.log('Generated: /_redirects')
|
|
|
|
// Generate 404.html as SPA fallback for unmatched routes (like /@handle)
|
|
fs.writeFileSync(path.join(distDir, '404.html'), spaHtml)
|
|
console.log('Generated: /404.html')
|
|
|
|
// 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)
|
|
})
|
|
}
|