881 lines
32 KiB
TypeScript
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>© ${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)
|
|
})
|
|
}
|