add translate

This commit is contained in:
2026-01-16 11:59:21 +09:00
parent 2533720014
commit b8922f38be
17 changed files with 1062 additions and 20 deletions

View File

@@ -114,6 +114,7 @@ async function listRecordsFromApi(did: string, collection: string, pdsUrl: strin
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())
}
@@ -394,22 +395,68 @@ function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string
`
}
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">${escapeHtml(post.title)}</span>
<span class="post-title" data-rkey="${rkey}">${escapeHtml(displayTitle)}</span>
<span class="post-date">${formatDate(post.createdAt)}</span>
</a>
</li>
`
}).join('')
return `<ul class="post-list">${items}</ul>`
return `
<div class="content-header">${generateLangSelectorHtml()}</div>
${translationScript}
<ul class="post-list">${items}</ul>
`
}
// Map network to app URL for discussion links
@@ -423,7 +470,15 @@ function getAppUrl(network: string): string {
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
const rkey = post.uri.split('/').pop() || ''
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
const content = marked.parse(post.content) as string
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}/`
@@ -439,16 +494,32 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
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">${escapeHtml(post.title)}</h1>
<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">${content}</div>
<div class="post-content">${displayContent}</div>
</article>
<div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
@@ -645,16 +716,32 @@ async function generate() {
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
console.log(`Found ${localPosts.length} posts from local`)
// Merge: API is the source of truth
// - If post exists in API: always use API (has latest edits)
// - If post exists in local only: keep if not deleted (for posts beyond API limit)
// 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)
// Note: these might be deleted posts, so we keep them cautiously
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
posts = [...apiPosts, ...oldLocalPosts].sort((a, b) =>
posts = [...mergedApiPosts, ...oldLocalPosts].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)