From fbd1a978b16be0a36703c9a4208c48443d0a6bee Mon Sep 17 00:00:00 2001 From: syui Date: Thu, 15 Jan 2026 19:46:01 +0900 Subject: [PATCH] fix post generate --- .github/workflows/cf-pages.yml | 9 +- .gitignore | 1 + package.json | 9 +- readme.md | 35 ++ scripts/generate.ts | 717 +++++++++++++++++++++++++++++++++ src/components/atbrowser.ts | 16 +- src/components/posts.ts | 7 +- src/components/services.ts | 2 +- src/lib/markdown.ts | 71 ++++ src/lib/router.ts | 95 +++++ src/main.ts | 521 +++++++++++++++++++----- src/styles/main.css | 340 +++++++++++++++- 12 files changed, 1705 insertions(+), 118 deletions(-) create mode 100644 readme.md create mode 100644 scripts/generate.ts create mode 100644 src/lib/markdown.ts create mode 100644 src/lib/router.ts diff --git a/.github/workflows/cf-pages.yml b/.github/workflows/cf-pages.yml index 03ec0b7..2e2661c 100644 --- a/.github/workflows/cf-pages.yml +++ b/.github/workflows/cf-pages.yml @@ -21,10 +21,13 @@ jobs: uses: actions/setup-node@v4 - name: Install dependencies - run: npm i + run: npm install - - name: Build - run: npm run build + - name: Fetch content from ATProto + run: npm run fetch + + - name: Generate static site + run: npm run generate - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 diff --git a/.gitignore b/.gitignore index 0062c02..e87a534 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist node_modules package-lock.json repos +content/ diff --git a/package.json b/package.json index f2af4a8..c7fda56 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,18 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "fetch": "tsx scripts/generate.ts fetch", + "generate": "npm run build && tsx scripts/generate.ts" }, "dependencies": { "@atproto/api": "^0.15.8", - "@atproto/oauth-client-browser": "^0.3.39" + "@atproto/oauth-client-browser": "^0.3.39", + "highlight.js": "^11.11.1", + "marked": "^17.0.1" }, "devDependencies": { + "tsx": "^4.21.0", "typescript": "^5.7.0", "vite": "^6.0.0" } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..27c62e8 --- /dev/null +++ b/readme.md @@ -0,0 +1,35 @@ +# ailog + +## config + +```sh +$ ls public/ +config.json +networks.json +client-metadata.json +``` + +## preview + +```sh +$ npm run dev +``` + +## content generate + +speed up by using local cache + +```sh +$ npm run fetch # API download -> content/${did}/... +$ npm run generate # content html -> dist/ +$ npm run preview # dist/ preview server +``` + +```sh +content/ +└── did:plc:uqzpqmrjnptsxezjx4xuh2mn/ + ├── profile.json + └── ai.syui.log.post/ + └── 3mch5zca4nj2h.json +``` + diff --git a/scripts/generate.ts b/scripts/generate.ts new file mode 100644 index 0000000..2154044 --- /dev/null +++ b/scripts/generate.ts @@ -0,0 +1,717 @@ +import * as fs from 'fs' +import * as path from 'path' +import { marked, Renderer } from 'marked' + +// Types +interface AppConfig { + title: string + handle: string + collection: string + network: string + color?: string +} + +interface Networks { + [key: string]: { + plc: string + bsky: string + } +} + +interface Profile { + did: string + handle: string + displayName?: string + description?: string + avatar?: string +} + +interface BlogPost { + uri: string + cid: string + title: string + content: string + createdAt: string +} + +// Highlight.js for syntax highlighting (core + common languages only) +let hljs: typeof import('highlight.js/lib/core').default + +async function loadHighlightJs() { + const core = await import('highlight.js/lib/core') + hljs = core.default + + const [js, ts, bash, json, yaml, md, css, xml, py, rust, go] = await Promise.all([ + import('highlight.js/lib/languages/javascript'), + import('highlight.js/lib/languages/typescript'), + import('highlight.js/lib/languages/bash'), + import('highlight.js/lib/languages/json'), + import('highlight.js/lib/languages/yaml'), + import('highlight.js/lib/languages/markdown'), + import('highlight.js/lib/languages/css'), + import('highlight.js/lib/languages/xml'), + import('highlight.js/lib/languages/python'), + import('highlight.js/lib/languages/rust'), + import('highlight.js/lib/languages/go'), + ]) + + hljs.registerLanguage('javascript', js.default) + hljs.registerLanguage('js', js.default) + hljs.registerLanguage('typescript', ts.default) + hljs.registerLanguage('ts', ts.default) + hljs.registerLanguage('bash', bash.default) + hljs.registerLanguage('sh', bash.default) + hljs.registerLanguage('json', json.default) + hljs.registerLanguage('yaml', yaml.default) + hljs.registerLanguage('yml', yaml.default) + hljs.registerLanguage('markdown', md.default) + hljs.registerLanguage('md', md.default) + hljs.registerLanguage('css', css.default) + hljs.registerLanguage('html', xml.default) + hljs.registerLanguage('xml', xml.default) + hljs.registerLanguage('python', py.default) + hljs.registerLanguage('py', py.default) + hljs.registerLanguage('rust', rust.default) + hljs.registerLanguage('go', go.default) +} + +// Markdown renderer +function setupMarked() { + const renderer = new Renderer() + + renderer.code = function({ text, lang }: { text: string; lang?: string }) { + let highlighted: string + if (lang && hljs.getLanguage(lang)) { + try { + highlighted = hljs.highlight(text, { language: lang }).value + } catch { + highlighted = escapeHtml(text) + } + } else { + highlighted = escapeHtml(text) + } + return `
${highlighted}
` + } + + marked.setOptions({ + breaks: true, + gfm: true, + renderer, + }) +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr) + return date.toLocaleDateString('ja-JP', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) +} + +// API functions +async function resolveHandle(handle: string, bskyUrl: string): Promise { + const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`) + if (!res.ok) throw new Error('Failed to resolve handle') + const data = await res.json() as { did: string } + return data.did +} + +async function getPdsEndpoint(did: string, plcUrl: string): Promise { + const res = await fetch(`${plcUrl}/${did}`) + if (!res.ok) throw new Error('Failed to resolve DID') + const doc = await res.json() as { service: Array<{ type: string; serviceEndpoint: string }> } + const pds = doc.service.find(s => s.type === 'AtprotoPersonalDataServer') + if (!pds) throw new Error('PDS not found in DID document') + return pds.serviceEndpoint +} + +async function getProfile(did: string, bskyUrl: string): Promise { + const res = await fetch(`${bskyUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`) + if (!res.ok) throw new Error('Failed to get profile') + return res.json() as Promise +} + +async function listRecordsFromApi(did: string, collection: string, pdsUrl: string): Promise { + const res = await fetch( + `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100` + ) + if (!res.ok) return [] + const data = await res.json() as { records: Array<{ uri: string; cid: string; value: Record }> } + return data.records.map(r => ({ + uri: r.uri, + cid: r.cid, + title: r.value.title as string || 'Untitled', + content: r.value.content as string || '', + createdAt: r.value.createdAt as string || new Date().toISOString(), + })).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) +} + +async function describeRepo(did: string, pdsUrl: string): Promise { + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.describeRepo?repo=${did}`) + if (!res.ok) return [] + const data = await res.json() as { collections: string[] } + return data.collections || [] +} + +// Content file functions +function getContentDir(): string { + return path.join(process.cwd(), 'content') +} + +function getCollectionDir(did: string, collection: string): string { + return path.join(getContentDir(), did, collection) +} + +function getProfilePath(did: string): string { + return path.join(getContentDir(), did, 'profile.json') +} + +function savePostToFile(post: BlogPost, did: string, collection: string): string { + const collectionDir = getCollectionDir(did, collection) + if (!fs.existsSync(collectionDir)) { + fs.mkdirSync(collectionDir, { recursive: true }) + } + + const rkey = post.uri.split('/').pop()! + const jsonPath = path.join(collectionDir, `${rkey}.json`) + fs.writeFileSync(jsonPath, JSON.stringify(post, null, 2)) + + return rkey +} + +function saveProfile(profile: Profile): void { + const profileDir = path.join(getContentDir(), profile.did) + if (!fs.existsSync(profileDir)) { + fs.mkdirSync(profileDir, { recursive: true }) + } + fs.writeFileSync(path.join(profileDir, 'profile.json'), JSON.stringify(profile, null, 2)) +} + +function loadPostsFromFiles(did: string, collection: string): BlogPost[] { + const collectionDir = getCollectionDir(did, collection) + if (!fs.existsSync(collectionDir)) { + return [] + } + + const files = fs.readdirSync(collectionDir) + const jsonFiles = files.filter(f => f.endsWith('.json')) + + const posts: BlogPost[] = [] + for (const jsonFile of jsonFiles) { + const jsonPath = path.join(collectionDir, jsonFile) + const post: BlogPost = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) + posts.push(post) + } + + return posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) +} + +function loadProfile(did: string): Profile | null { + const profilePath = getProfilePath(did) + if (!fs.existsSync(profilePath)) return null + return JSON.parse(fs.readFileSync(profilePath, 'utf-8')) +} + +function hasLocalContent(did: string, collection: string): boolean { + const collectionDir = getCollectionDir(did, collection) + if (!fs.existsSync(collectionDir)) return false + const files = fs.readdirSync(collectionDir) + return files.some(f => f.endsWith('.json')) +} + +function findLocalDid(): string | null { + const contentDir = getContentDir() + if (!fs.existsSync(contentDir)) return null + const dirs = fs.readdirSync(contentDir) + const didDir = dirs.find(d => d.startsWith('did:')) + return didDir || null +} + +// Favicon functions +function getFaviconDir(did: string): string { + return path.join(getContentDir(), did, 'favicons') +} + +async function downloadFavicon(url: string, filepath: string): Promise { + try { + const res = await fetch(url) + if (!res.ok) return false + const buffer = await res.arrayBuffer() + fs.writeFileSync(filepath, Buffer.from(buffer)) + return true + } catch { + return false + } +} + +// Service domain mapping +function getServiceDomain(collection: string): string | null { + const prefixMap: Record = { + 'app.bsky': 'bsky.app', + 'chat.bsky': 'bsky.app', + 'ai.syui': 'syui.ai', + } + for (const [prefix, domain] of Object.entries(prefixMap)) { + if (collection.startsWith(prefix)) { + return domain + } + } + return null +} + +function getServiceDomains(collections: string[]): string[] { + const domains = new Set() + for (const col of collections) { + const domain = getServiceDomain(col) + if (domain) domains.add(domain) + } + return Array.from(domains) +} + +async function downloadFavicons(did: string, domains: string[]): Promise { + const faviconDir = getFaviconDir(did) + if (!fs.existsSync(faviconDir)) { + fs.mkdirSync(faviconDir, { recursive: true }) + } + + const faviconUrls: Record = { + 'bsky.app': 'https://bsky.app/static/favicon-32x32.png', + 'syui.ai': 'https://syui.ai/favicon.png', + } + + for (const domain of domains) { + const url = faviconUrls[domain] + if (!url) continue + + const filepath = path.join(faviconDir, `${domain}.png`) + if (!fs.existsSync(filepath)) { + const ok = await downloadFavicon(url, filepath) + if (ok) { + console.log(`Downloaded: ${domain}.png`) + } + } + } +} + +function getLocalFaviconPath(did: string, domain: string): string | null { + const faviconDir = getFaviconDir(did) + const filename = `${domain}.png` + const filepath = path.join(faviconDir, filename) + if (fs.existsSync(filepath)) { + return `/favicons/${filename}` + } + return null +} + +// HTML Templates +function getAssetFilenames(): { css: string; js: string } { + const assetsDir = path.join(process.cwd(), 'dist/assets') + if (!fs.existsSync(assetsDir)) { + return { css: 'index.css', js: 'index.js' } + } + const files = fs.readdirSync(assetsDir) + const css = files.find(f => f.endsWith('.css')) || 'index.css' + const js = files.find(f => f.endsWith('.js')) || 'index.js' + return { css, js } +} + +function generateHtml(title: string, content: string, config: AppConfig, assets: { css: string; js: string }, isStatic: boolean = true): string { + const staticAttr = isStatic ? ' data-static="true"' : '' + return ` + + + + + ${escapeHtml(title)} - ${escapeHtml(config.title)} + + + ${config.color ? `` : ''} + + +
+ ${content} +
+ + +` +} + +function generateProfileHtml(profile: Profile): string { + const avatar = profile.avatar + ? `` + : '' + return ` +
+ ${avatar} +
+
${escapeHtml(profile.displayName || profile.handle)}
+
@${escapeHtml(profile.handle)}
+ ${profile.description ? `
${escapeHtml(profile.description)}
` : ''} +
+
+ ` +} + +function loadCollections(did: string): string[] { + const collectionsPath = path.join(getContentDir(), did, 'collections.json') + if (!fs.existsSync(collectionsPath)) return [] + return JSON.parse(fs.readFileSync(collectionsPath, 'utf-8')) +} + +function generateServicesHtml(did: string, handle: string, collections: string[]): string { + const serviceMap = new Map() + + for (const col of collections) { + const domain = getServiceDomain(col) + if (domain) { + if (!serviceMap.has(domain)) { + serviceMap.set(domain, []) + } + serviceMap.get(domain)!.push(col) + } + } + + if (serviceMap.size === 0) return '' + + const items = Array.from(serviceMap.entries()).map(([domain, cols]) => { + // Use local favicon if exists + const localFavicon = getLocalFaviconPath(did, domain) + const faviconSrc = localFavicon || `https://www.google.com/s2/favicons?domain=${domain}&sz=32` + + return ` + + + ${domain} + + ` + }).join('') + + return `
${items}
` +} + +function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string { + return ` +
+ Blog + +
+ ` +} + +function generatePostListHtml(posts: BlogPost[]): string { + if (posts.length === 0) { + return '

No posts yet

' + } + const items = posts.map(post => { + const rkey = post.uri.split('/').pop() + return ` +
  • + + ${escapeHtml(post.title)} + + +
  • + ` + }).join('') + return `
      ${items}
    ` +} + +function generatePostDetailHtml(post: BlogPost, handle: string, collection: string): string { + const rkey = post.uri.split('/').pop() || '' + const jsonUrl = `/at/${handle}/${collection}/${rkey}/` + const content = marked.parse(post.content) as string + + return ` +
    +
    +

    ${escapeHtml(post.title)}

    + +
    +
    ${content}
    +
    + ` +} + +function generateFooterHtml(handle: string): string { + const username = handle.split('.')[0] || handle + return ` +
    +

    © ${username}

    +
    + ` +} + +function generateIndexPageContent(profile: Profile, posts: BlogPost[], config: AppConfig, collections: string[]): string { + return ` + +
    +
    + ${generateTabsHtml('blog', config.handle)} + ${generateProfileHtml(profile)} + ${generateServicesHtml(profile.did, config.handle, collections)} +
    +
    + ${generatePostListHtml(posts)} +
    +
    + ${generateFooterHtml(config.handle)} + ` +} + +function generatePostPageContent(profile: Profile, post: BlogPost, config: AppConfig, collections: string[]): string { + return ` + +
    +
    + ${generateTabsHtml('blog', config.handle)} + ${generateProfileHtml(profile)} + ${generateServicesHtml(profile.did, config.handle, collections)} +
    +
    + ${generatePostDetailHtml(post, config.handle, config.collection)} +
    +
    + ${generateFooterHtml(config.handle)} + ` +} + +// Fetch command - downloads posts from ATProto and saves to content/ +async function fetchPosts() { + console.log('Fetching posts from ATProto...') + + const configPath = path.join(process.cwd(), 'public/config.json') + const config: AppConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + + const networksPath = path.join(process.cwd(), 'public/networks.json') + const networks: Networks = JSON.parse(fs.readFileSync(networksPath, 'utf-8')) + const network = networks[config.network] + if (!network) { + throw new Error(`Network ${config.network} not found`) + } + + const did = await resolveHandle(config.handle, network.bsky) + console.log(`DID: ${did}`) + + const pdsUrl = await getPdsEndpoint(did, network.plc) + console.log(`PDS: ${pdsUrl}`) + + // Save profile + const profile = await getProfile(did, network.bsky) + saveProfile(profile) + console.log(`Saved: content/${did}/profile.json`) + + // Get and save collections + const collections = await describeRepo(did, pdsUrl) + const collectionsPath = path.join(getContentDir(), did, 'collections.json') + fs.writeFileSync(collectionsPath, JSON.stringify(collections, null, 2)) + console.log(`Saved: content/${did}/collections.json`) + + // Download favicons for each service domain + const domains = getServiceDomains(collections) + await downloadFavicons(did, domains) + + // Save posts + const posts = await listRecordsFromApi(did, config.collection, pdsUrl) + console.log(`Found ${posts.length} posts`) + + for (const post of posts) { + const rkey = savePostToFile(post, did, config.collection) + console.log(`Saved: content/${did}/${config.collection}/${rkey}.json`) + } + + console.log('\nFetch complete!') +} + +// Generate command - builds HTML from content/ files (or API if no local files) +async function generate() { + console.log('Starting static generation...') + + // Load config + const configPath = path.join(process.cwd(), 'public/config.json') + const config: AppConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + console.log(`Config loaded: ${config.handle}`) + + // Load networks + const networksPath = path.join(process.cwd(), 'public/networks.json') + const networks: Networks = JSON.parse(fs.readFileSync(networksPath, 'utf-8')) + const network = networks[config.network] + if (!network) { + throw new Error(`Network ${config.network} not found in networks.json`) + } + + // Setup markdown + await loadHighlightJs() + setupMarked() + + // Always fetch from API to get latest posts, merge with local + const localDid = findLocalDid() + let did: string + let profile: Profile + let posts: BlogPost[] + + // Resolve DID and get profile + did = await resolveHandle(config.handle, network.bsky) + profile = await getProfile(did, network.bsky) + const pdsUrl = await getPdsEndpoint(did, network.plc) + console.log(`Profile: ${profile.displayName || profile.handle}`) + + // Fetch posts from API + const apiPosts = await listRecordsFromApi(did, config.collection, pdsUrl) + console.log(`Found ${apiPosts.length} posts from API`) + + // Load local posts if they exist + const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : [] + console.log(`Found ${localPosts.length} posts from local`) + + // Merge: API is the source of truth for what exists + // - If post exists in API and local: use local (may have edits) + // - If post exists in API only: use API + // - If post exists in local only: skip (was deleted from API) + const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop())) + const localRkeys = new Set(localPosts.map(p => p.uri.split('/').pop())) + + // Local posts that still exist in API + const validLocalPosts = localPosts.filter(p => apiRkeys.has(p.uri.split('/').pop())) + // API posts that don't exist locally + const newApiPosts = apiPosts.filter(p => !localRkeys.has(p.uri.split('/').pop())) + + posts = [...validLocalPosts, ...newApiPosts].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + + const deletedCount = localPosts.length - validLocalPosts.length + if (deletedCount > 0) { + console.log(`Skipped ${deletedCount} deleted posts (exist locally but not in API)`) + } + console.log(`Total ${posts.length} posts (${validLocalPosts.length} local + ${newApiPosts.length} new from API)`) + + // Create output directory + const distDir = path.join(process.cwd(), 'dist') + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }) + } + + // Get asset filenames (with hashes) + const assets = getAssetFilenames() + + // Load collections for services display + const collections = loadCollections(did) + + // Generate index page + const indexContent = generateIndexPageContent(profile, posts, config, collections) + const indexHtml = generateHtml(config.title, indexContent, config, assets) + fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml) + console.log('Generated: /index.html') + + // Generate post pages + const postsDir = path.join(distDir, 'post') + if (!fs.existsSync(postsDir)) { + fs.mkdirSync(postsDir, { recursive: true }) + } + + // Generate /post/index.html as SPA for new post form + const postFormContent = ` + +
    +
    +
    +
    +
    + ` + const postFormHtml = generateHtml('New Post', postFormContent, config, assets, false) + fs.writeFileSync(path.join(postsDir, 'index.html'), postFormHtml) + console.log('Generated: /post/index.html') + + for (const post of posts) { + const rkey = post.uri.split('/').pop() + if (!rkey) continue + + const postDir = path.join(postsDir, rkey) + if (!fs.existsSync(postDir)) { + fs.mkdirSync(postDir, { recursive: true }) + } + + const postContent = generatePostPageContent(profile, post, config, collections) + const postHtml = generateHtml(post.title, postContent, config, assets) + fs.writeFileSync(path.join(postDir, 'index.html'), postHtml) + console.log(`Generated: /post/${rkey}/index.html`) + } + + // Generate SPA fallback page for dynamic routes (no data-static attribute) + const spaContent = ` + +
    +
    + +
    +
    +
    + ` + const spaHtml = generateHtml('App', spaContent, config, assets, false) + fs.writeFileSync(path.join(distDir, 'app.html'), spaHtml) + console.log('Generated: /app.html') + + // Generate _redirects for Cloudflare Pages (SPA routes) + const redirects = `/app / 301 +/oauth/* /app.html 200 +` + fs.writeFileSync(path.join(distDir, '_redirects'), redirects) + console.log('Generated: /_redirects') + + // Copy static files + const filesToCopy = ['favicon.png', 'favicon.svg', 'config.json', 'networks.json', 'client-metadata.json'] + for (const file of filesToCopy) { + const src = path.join(process.cwd(), 'public', file) + const dest = path.join(distDir, file) + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest) + } + } + + // Copy .well-known directory + const wellKnownSrc = path.join(process.cwd(), 'public/.well-known') + const wellKnownDest = path.join(distDir, '.well-known') + if (fs.existsSync(wellKnownSrc)) { + fs.cpSync(wellKnownSrc, wellKnownDest, { recursive: true }) + } + + // Copy favicons from content + const faviconSrc = getFaviconDir(did) + const faviconDest = path.join(distDir, 'favicons') + if (fs.existsSync(faviconSrc)) { + if (!fs.existsSync(faviconDest)) { + fs.mkdirSync(faviconDest, { recursive: true }) + } + fs.cpSync(faviconSrc, faviconDest, { recursive: true }) + } + + console.log('\nStatic generation complete!') + console.log(`Output: ${distDir}`) +} + +// CLI +const command = process.argv[2] + +if (command === 'fetch') { + fetchPosts().catch(err => { + console.error('Fetch failed:', err) + process.exit(1) + }) +} else { + generate().catch(err => { + console.error('Generation failed:', err) + process.exit(1) + }) +} diff --git a/src/components/atbrowser.ts b/src/components/atbrowser.ts index 1502140..4176093 100644 --- a/src/components/atbrowser.ts +++ b/src/components/atbrowser.ts @@ -47,7 +47,7 @@ async function renderServices(did: string, handle: string): Promise { const items = Array.from(serviceMap.entries()).map(([domain, info]) => { return `
  • - + ${info.name} ${info.count} @@ -84,7 +84,7 @@ async function renderCollections(did: string, handle: string, serviceDomain: str const items = filtered.map(col => { return `
  • - + ${col}
  • @@ -111,7 +111,7 @@ async function renderRecordList(did: string, handle: string, collection: string) const preview = rec.value.title || rec.value.text?.slice(0, 50) || rkey return `
  • - + ${rkey} ${preview} @@ -168,7 +168,7 @@ export async function mountAtBrowser( service: string | null = null, loginDid: string | null = null ): Promise { - container.innerHTML = '

    Loading...

    ' + container.innerHTML = '
    ' try { const did = handle.startsWith('did:') ? handle : await resolveHandle(handle) @@ -178,16 +178,16 @@ export async function mountAtBrowser( let nav = '' if (collection && rkey) { - nav = `← Back` + nav = `← Back` content = await renderRecordDetail(did, handle, collection, rkey, canDelete) } else if (collection) { // Get service from collection for back link const info = getServiceInfo(collection) const backService = info ? info.domain : '' - nav = `← ${info?.name || 'Back'}` + nav = `← ${info?.name || 'Back'}` content = await renderRecordList(did, handle, collection) } else if (service) { - nav = `← Services` + nav = `← Services` content = await renderCollections(did, handle, service) } else { content = await renderServices(did, handle) @@ -213,7 +213,7 @@ export async function mountAtBrowser( btn.textContent = 'Deleting...' await deleteRecord(col, rk) // Go back to collection - window.location.href = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(col)}` + window.location.href = `/at/${handle}/${col}` } catch (err) { alert('Delete failed: ' + err) btn.disabled = false diff --git a/src/components/posts.ts b/src/components/posts.ts index b1f4c11..0ec8aa2 100644 --- a/src/components/posts.ts +++ b/src/components/posts.ts @@ -1,5 +1,6 @@ import type { BlogPost } from '../types.js' import { putRecord } from '../lib/auth.js' +import { renderMarkdown } from '../lib/markdown.js' function formatDate(dateStr: string): string { const date = new Date(dateStr) @@ -28,7 +29,7 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void { const rkey = post.uri.split('/').pop() return `
  • - + ${escapeHtml(post.title)} @@ -41,7 +42,7 @@ export function mountPostList(container: HTMLElement, posts: BlogPost[]): void { export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: string, collection: string, canEdit: boolean = false): void { const rkey = post.uri.split('/').pop() || '' - const jsonUrl = `?mode=browser&handle=${handle}&collection=${encodeURIComponent(collection)}&rkey=${rkey}` + const jsonUrl = `/at/${handle}/${collection}/${rkey}` const editBtn = canEdit ? `` : '' @@ -55,7 +56,7 @@ export function mountPostDetail(container: HTMLElement, post: BlogPost, handle: ${editBtn} -
    ${escapeHtml(post.content)}
    +
    ${renderMarkdown(post.content)}