refact update

This commit is contained in:
2026-01-16 14:10:18 +09:00
parent 2ec33ef4ed
commit 40815a3b60
10 changed files with 60 additions and 90 deletions

View File

@@ -2,7 +2,9 @@ import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import { marked, Renderer } from 'marked' import { marked, Renderer } from 'marked'
import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts' import type { AppConfig, Profile, BlogPost, Networks } from '../src/types.ts'
import { escapeHtml } from '../src/lib/utils.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) // Highlight.js for syntax highlighting (core + common languages only)
let hljs: typeof import('highlight.js/lib/core').default let hljs: typeof import('highlight.js/lib/core').default
@@ -70,15 +72,6 @@ function setupMarked() {
}) })
} }
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 // API functions
async function resolveHandle(handle: string, bskyUrl: string): Promise<string> { async function resolveHandle(handle: string, bskyUrl: string): Promise<string> {
const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`) const res = await fetch(`${bskyUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`)
@@ -396,12 +389,10 @@ function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string
} }
function generateLangSelectorHtml(): 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 ` return `
<div class="lang-selector" id="lang-selector"> <div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language"> <button type="button" class="lang-btn" id="lang-btn" title="Language">
${langIcon} ${LANG_ICON}
</button> </button>
<div class="lang-dropdown" id="lang-dropdown"> <div class="lang-dropdown" id="lang-dropdown">
<div class="lang-option" data-lang="ja"> <div class="lang-option" data-lang="ja">
@@ -484,8 +475,6 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
const postUrl = `${baseSiteUrl}/post/${rkey}/` const postUrl = `${baseSiteUrl}/post/${rkey}/`
const appUrl = getAppUrl(network) const appUrl = getAppUrl(network)
// Convert to search-friendly format (domain/post/rkey_prefix without https://) // 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 urlObj = new URL(postUrl)
const pathParts = urlObj.pathname.split('/').filter(Boolean) const pathParts = urlObj.pathname.split('/').filter(Boolean)
const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/' const basePath = urlObj.host + '/' + (pathParts[0] || '') + '/'
@@ -550,14 +539,6 @@ function loadLinks(): FooterLink[] {
} }
} }
// 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 { function generateFooterLinksHtml(links: FooterLink[]): string {
if (links.length === 0) return '' if (links.length === 0) return ''

View File

@@ -86,16 +86,3 @@ export function mountHeader(
}) })
} }
} }
// Keep old function for compatibility
export function mountBrowser(
container: HTMLElement,
currentHandle: string,
onSubmit: (handle: string) => void
): void {
mountHeader(container, currentHandle, false, undefined, {
onBrowse: onSubmit,
onLogin: () => {},
onLogout: () => {}
})
}

View File

@@ -1,5 +1,6 @@
import { searchPostsForUrl } from '../lib/api.js' import { searchPostsForUrl } from '../lib/api.js'
import { escapeHtml } from '../lib/utils.js' import { escapeHtml, formatDate } from '../lib/utils.js'
import { MAX_SEARCH_LENGTH, DISCUSSION_POST_LIMIT } from '../lib/constants.js'
// Map network to app URL // Map network to app URL
export function getAppUrl(network: string): string { export function getAppUrl(network: string): string {
@@ -9,15 +10,6 @@ export function getAppUrl(network: string): string {
return 'https://bsky.app' return 'https://bsky.app'
} }
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function getPostUrl(uri: string, appUrl: string): string { function getPostUrl(uri: string, appUrl: string): string {
// at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey // at://did:plc:xxx/app.bsky.feed.post/rkey -> {appUrl}/profile/did:plc:xxx/post/rkey
const parts = uri.replace('at://', '').split('/') const parts = uri.replace('at://', '').split('/')
@@ -29,8 +21,6 @@ function getPostUrl(uri: string, appUrl: string): string {
export function renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string { export function renderDiscussionLink(postUrl: string, appUrl: string = 'https://bsky.app'): string {
// Convert full URL to search-friendly format (domain/post/rkey_prefix without https://) // Convert full URL 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
let searchQuery = postUrl let searchQuery = postUrl
try { try {
const urlObj = new URL(postUrl) const urlObj = new URL(postUrl)
@@ -74,7 +64,7 @@ export async function loadDiscussionPosts(container: HTMLElement, postUrl: strin
return return
} }
const postsHtml = posts.slice(0, 10).map(post => { const postsHtml = posts.slice(0, DISCUSSION_POST_LIMIT).map(post => {
const author = post.author const author = post.author
const avatar = author.avatar || '' const avatar = author.avatar || ''
const displayName = author.displayName || author.handle const displayName = author.displayName || author.handle

View File

@@ -1,18 +1,9 @@
import type { BlogPost } from '../types.js' import type { BlogPost } from '../types.js'
import { putRecord } from '../lib/auth.js' import { putRecord } from '../lib/auth.js'
import { renderMarkdown } from '../lib/markdown.js' import { renderMarkdown } from '../lib/markdown.js'
import { escapeHtml } from '../lib/utils.js' import { escapeHtml, formatDate } from '../lib/utils.js'
import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js' import { renderDiscussionLink, loadDiscussionPosts, getAppUrl } from './discussion.js'
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
export function mountPostList(container: HTMLElement, posts: BlogPost[]): void { export function mountPostList(container: HTMLElement, posts: BlogPost[]): void {
if (posts.length === 0) { if (posts.length === 0) {
container.innerHTML = '<p class="no-posts">No posts yet</p>' container.innerHTML = '<p class="no-posts">No posts yet</p>'

View File

@@ -1,5 +1,6 @@
import { AtpAgent } from '@atproto/api' import { AtpAgent } from '@atproto/api'
import type { Profile, BlogPost, NetworkConfig } from '../types.js' import type { Profile, BlogPost, NetworkConfig } from '../types.js'
import { FALLBACK_PLCS, FALLBACK_BSKY_ENDPOINTS, SEARCH_TIMEOUT_MS } from './constants.js'
const agents: Map<string, AtpAgent> = new Map() const agents: Map<string, AtpAgent> = new Map()
@@ -24,18 +25,6 @@ export function getAgent(service: string): AtpAgent {
return agents.get(service)! return agents.get(service)!
} }
// Fallback PLC directories
const FALLBACK_PLCS = [
'https://plc.directory',
'https://plc.syu.is',
]
// Fallback endpoints for handle/profile resolution
const FALLBACK_ENDPOINTS = [
'https://public.api.bsky.app',
'https://bsky.syu.is',
]
export async function resolvePds(did: string): Promise<string> { export async function resolvePds(did: string): Promise<string> {
// Try current PLC first, then fallbacks // Try current PLC first, then fallbacks
const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())] const plcs = [getPlc(), ...FALLBACK_PLCS.filter(p => p !== getPlc())]
@@ -64,7 +53,7 @@ export async function resolveHandle(handle: string): Promise<string> {
return res.data.did return res.data.did
} catch { } catch {
// Try fallback endpoints // Try fallback endpoints
for (const endpoint of FALLBACK_ENDPOINTS) { for (const endpoint of FALLBACK_BSKY_ENDPOINTS) {
if (endpoint === getBsky()) continue // Skip if same as current if (endpoint === getBsky()) continue // Skip if same as current
try { try {
const agent = getAgent(endpoint) const agent = getAgent(endpoint)
@@ -80,7 +69,7 @@ export async function resolveHandle(handle: string): Promise<string> {
export async function getProfile(actor: string): Promise<Profile> { export async function getProfile(actor: string): Promise<Profile> {
// Try current network first // Try current network first
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())] const endpoints = [getBsky(), ...FALLBACK_BSKY_ENDPOINTS.filter(e => e !== getBsky())]
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
try { try {
@@ -273,7 +262,7 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
searchQueries.map(async query => { searchQueries.map(async query => {
try { try {
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5s timeout const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS)
const res = await fetch( const res = await fetch(
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`, `${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,

19
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,19 @@
// API limits
export const API_RECORD_LIMIT = 100
export const POST_LIST_LIMIT = 50
export const DISCUSSION_POST_LIMIT = 10
// Search
export const MAX_SEARCH_LENGTH = 20
export const SEARCH_TIMEOUT_MS = 5000
// Fallback endpoints
export const FALLBACK_PLCS = [
'https://plc.directory',
'https://plc.syu.is',
]
export const FALLBACK_BSKY_ENDPOINTS = [
'https://public.api.bsky.app',
'https://bsky.syu.is',
]

17
src/lib/icons.ts Normal file
View File

@@ -0,0 +1,17 @@
// Shared icon definitions
export const LANG_ICON = `<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>`
export const DISCUSS_ICON = `<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>`
export const LOGIN_ICON = `<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>`
export const LOGOUT_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>`
// Footer link icons
export 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>`,
}

View File

@@ -1,5 +1,6 @@
import { marked, Renderer } from 'marked' import { marked, Renderer } from 'marked'
import hljs from 'highlight.js/lib/core' import hljs from 'highlight.js/lib/core'
import { escapeHtml } from './utils.js'
// Import only common languages // Import only common languages
import javascript from 'highlight.js/lib/languages/javascript' import javascript from 'highlight.js/lib/languages/javascript'
@@ -53,13 +54,6 @@ renderer.code = function({ text, lang }: { text: string; lang?: string }) {
return `<pre><code class="hljs">${highlighted}</code></pre>` return `<pre><code class="hljs">${highlighted}</code></pre>`
} }
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
marked.setOptions({ marked.setOptions({
breaks: true, breaks: true,
gfm: true, gfm: true,

View File

@@ -5,3 +5,12 @@ export function escapeHtml(str: string): string {
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
} }
export function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}

View File

@@ -9,6 +9,7 @@ import { mountPostForm } from './components/postform.js'
import { loadDiscussionPosts } from './components/discussion.js' import { loadDiscussionPosts } from './components/discussion.js'
import { parseRoute, type Route } from './lib/router.js' import { parseRoute, type Route } from './lib/router.js'
import { escapeHtml } from './lib/utils.js' import { escapeHtml } from './lib/utils.js'
import { LANG_ICON, BUILTIN_ICONS } from './lib/icons.js'
import type { AppConfig, Networks } from './types.js' import type { AppConfig, Networks } from './types.js'
let authSession: AuthSession | null = null let authSession: AuthSession | null = null
@@ -56,12 +57,6 @@ async function loadLinks(): Promise<FooterLink[]> {
} }
} }
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 renderFooterLinks(links: FooterLink[]): string { function renderFooterLinks(links: FooterLink[]): string {
if (links.length === 0) return '' if (links.length === 0) return ''
@@ -146,12 +141,10 @@ function renderLangSelector(): string {
</div>` </div>`
}).join('') }).join('')
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 ` return `
<div class="lang-selector" id="lang-selector"> <div class="lang-selector" id="lang-selector">
<button type="button" class="lang-btn" id="lang-btn" title="Language"> <button type="button" class="lang-btn" id="lang-btn" title="Language">
${langIcon} ${LANG_ICON}
</button> </button>
<div class="lang-dropdown" id="lang-dropdown"> <div class="lang-dropdown" id="lang-dropdown">
${options} ${options}