feat: implement ui i18n support

This commit is contained in:
草师傅 2025-08-11 16:06:14 +08:00
parent 457fb93718
commit 698c411c71
Signed by: gb
GPG key ID: 43330A030E2D6478
13 changed files with 162 additions and 54 deletions

View file

@ -35,6 +35,11 @@ export default defineConfig({
rehypePlugins: [rehypeKatex]
},
i18n: {
locales: ["en", "zh_hans"],
defaultLocale: "en",
},
integrations: [sitemap(), mdx(), partytown()],
adapter: cloudflare()

View file

@ -1,8 +1,13 @@
---
import {siteConfig} from "../config";
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
const noscript = siteConfig.noClientJavaScript
---
{noscript ? <a href="#top"><button id="toTopBtn" style="display: block" title="Go to top">Top</button></a> : <button id="toTopBtn" title="Go to top">Top</button>
{noscript ? <a href="#top"><button id="toTopBtn" style="display: block" title={t('widget.back_to_top.title')}>{t('widget.back_to_top')}</button></a> : <button id="toTopBtn" title={t('widget.back_to_top.title')}>{t('widget.back_to_top')}</button>
<script>
// Get the button
let toTopButton = document.getElementById("toTopBtn");

View file

@ -1,16 +1,21 @@
---
const { listmonkInstance, listuuid } = Astro.props;
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
const { listmonkInstance, listuuid } = Astro.props;
const inputId = Astro.props.listuuid.substring(0,5)
---
<details>
<summary>Subscribe to newsletter</summary>
<summary>{t('subscribe.newsletter')}}</summary>
<small>{t('subscribe.newsletter.description')}</small>
<form method="post" action={`https://${listmonkInstance}/subscription/form`} class="listmonk-form">
<div>
<input type="hidden" name="nonce" />
<input type="email" name="email" required placeholder="E-mail" />
<input type="text" name="name" placeholder="Name (optional)" />
<input type="email" name="email" required placeholder={t('subscribe.newsletter.email_label')} />
<input type="text" name="name" placeholder={t('subscribe.newsletter.name_label')} />
<input id={inputId} type="checkbox" name="l" checked="checked" value={listuuid} hidden />
<input type="submit" value="Subscribe" />

View file

@ -1,5 +1,10 @@
---
import {siteConfig} from "../config";
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
const { title, email = siteConfig.defaultAuthor.email } = Astro.props;
---
<a href={`mailto:${email}?subject=RE:${title}&body=Hi,\n\nI would like to reply to your post "${title}".`}>&#x21A9; Reply via Email</a>
<a href={`mailto:${email}?subject=RE:${title}&body=${t('article.reply_via_email.body')} "${title}".`}>&#x21A9; {t('article.reply_via_email')}</a>

View file

@ -1,5 +1,10 @@
---
import {siteConfig} from "../config";
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
const noscript = siteConfig.noClientJavaScript
const searchEngine = siteConfig.searchEngine || 'google'
const domain = Astro.url.host
@ -10,7 +15,7 @@ const domain = Astro.url.host
"https://www.google.com/search"} method="GET" target="_blank">
<div>
<label for="search-input"><span class="command">search</span></label>
<input name="q" type="text" id="search-input" class="search-input" autocomplete="off" placeholder="Type to search..." />
<input name="q" type="text" id="search-input" class="search-input" autocomplete="off" placeholder={t('search.placeholder')} />
{searchEngine === "duckduckgo" &&
<input type="hidden" name="sites" value={domain} />
}
@ -32,7 +37,7 @@ const domain = Astro.url.host
type="text"
id="search-input"
class="search-input"
placeholder="Type to search..."
placeholder={t('search.placeholder')}
autocomplete="off"
/>
</div>

View file

@ -1,5 +1,11 @@
---
import type { MarkdownHeading } from 'astro';
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
interface Props {
headings: MarkdownHeading[];
@ -34,7 +40,7 @@ function buildHierarchy(headings: MarkdownHeading[]) {
const toc = buildHierarchy(filteredHeadings);
---
<details>
<summary>Table of Contents</summary>
<summary>{t('article.toc')}</summary>
<ul class="toc-list">
{
toc.map((heading) => (

View file

@ -1,11 +1,16 @@
---
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
---
<div class="theme-dropdown" id="theme-dropdown">
<button class="theme-switcher" id="theme-switcher">Theme</button>
<button class="theme-switcher" id="theme-switcher">{t('widget.theme_mode')}</button>
<div class="menu-body" id="menu-body">
<div class="dropdown-item" data-theme="auto">System</div>
<div class="dropdown-item" data-theme="dark">Dark</div>
<div class="dropdown-item" data-theme="light">Light</div>
<div class="dropdown-item" data-theme="auto">{t('widget.theme_mode.auto')}</div>
<div class="dropdown-item" data-theme="dark">{t('widget.theme_mode.dark')}</div>
<div class="dropdown-item" data-theme="light">{t('widget.theme_mode.light')}</div>
</div>
</div>

View file

@ -1,10 +1,14 @@
---
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
---
<div class="theme-switcher">
<input type="checkbox" id="theme-toggle" class="theme-toggle" />
<label for="theme-toggle" class="theme-label">
<span>Theme</span>
<span>{t('widget.theme_mode')}</span>
</label>
</div>
<style>

View file

@ -1,6 +1,11 @@
---
import { Image } from 'astro:assets';
import { getMetadata, getWaybackMetadata } from '../../plugins/get-metadata';
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
interface Props {
url: string;
@ -67,8 +72,8 @@ const archiveUrl = showArchive ? await getWaybackMetadata(url, timestamp) : null
{showArchive && archiveUrl && (
<div class="link-card__archive">
<a href={archiveUrl} target="_blank" rel="noopener noreferrer" title="View archived version">
View archived version
<a href={archiveUrl} target="_blank" rel="noopener noreferrer" title={t('component.link_card.view_archived_version')}>
{t('component.link_card.view_archived_version')}
</a>
</div>
)}

View file

@ -1,6 +1,11 @@
---
import { encrypt } from '../../plugins/encrypt';
import {siteConfig} from "../../config";
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
interface Props {
password?: string;
@ -20,10 +25,10 @@ const { encryptedData, iv } = encrypt(content, password);
<div class="encrypted-content" data-encrypted={encryptedData} data-iv={iv}>
<div class="password-form">
<p>This content is protected. Enter the password to view it:</p>
<p>{t('component.protected_content.tip')}</p>
<div>
<input type="password" class="decrypt-password" title="password" />
<button class="decrypt-button">Decrypt</button>
<button class="decrypt-button">{t('component.protected_content.button_label')}</button>
</div>
</div>
<div class="content-container hidden"></div>

View file

@ -1,19 +1,65 @@
import { i18n } from "astro:config/client"
export const languages = {
en: 'English',
zh_hans: '中文(简体)',
};
export const defaultLang = 'en';
export const defaultLang: string = i18n?.defaultLocale || 'en';
export const showDefaultLang = false;
export const ui = {
en: {
'nav.home': 'Home',
'nav.about': 'About',
'nav.twitter': 'Twitter',
'nav.blog': 'Blog',
'search.placeholder': 'Search posts...',
'posts.description': 'Posts from the terminal.',
'subscribe.rss': 'Subscribe to RSS feed',
'subscribe.newsletter': 'Subscribe to Newsletter',
'subscribe.newsletter.description': 'Get the latest posts delivered right to your inbox.',
'subscribe.newsletter.email_label': 'E-mail',
'subscribe.newsletter.name_label': 'Name (optional)',
'widget.back_to_top': 'Top',
'widget.back_to_top.title': 'Back to top',
'widget.theme_mode': 'Theme',
'widget.theme_mode.light': 'Light',
'widget.theme_mode.dark': 'Dark',
'widget.theme_mode.auto': 'System',
'article.last_update': 'Updated on',
'article.word_count': 'words',
'article.toc': 'Table of Contents',
'article.reply_via_email': 'Reply via Email',
'article.reply_via_email.body': 'Hi,I would like to reply to your post',
'article.back_to_posts': 'Back to posts',
'article.comments': 'Comments',
'component.protected_content.tip': 'This content is protected. Enter the password to view it:',
'component.protected_content.button_label': 'Decrypt',
'component.link_card.view_archived_version': 'View archived version',
},
zh_hans: {
'nav.home': '首页',
'nav.about': '关于',
'nav.blog': '博客',
'search.placeholder': '搜索文章',
'subscribe.rss': '订阅 RSS',
'subscribe.newsletter': '订阅 Newsletter',
'subscribe.newsletter.description': '新文章第一时间送达您的邮箱。',
'subscribe.newsletter.email_label': '邮箱',
'subscribe.newsletter.name_label': '名称(可选)',
'widget.back_to_top': '回顶',
'widget.back_to_top.title': '回到顶部',
'widget.theme_mode': '主题',
'widget.theme_mode.light': '浅色主题',
'widget.theme_mode.dark': '深色主题',
'widget.theme_mode.auto': '跟随系统',
'article.last_update': '编辑于',
'article.word_count': '字',
'article.toc': '文章目录',
'article.reply_via_email': '邮件回复',
'article.reply_via_email.body': '您好,我想回复您的文章:',
'article.back_to_posts': '返回文章列表',
'article.comments': '评论区',
'component.protected_content.tip': '此内容已被加密,请输入密码以查看:',
'component.protected_content.button_label': '确定',
'component.link_card.view_archived_version': '查看互联网档案馆的存档版本',
},
} as const;

View file

@ -4,16 +4,20 @@ import { getCollection } from 'astro:content';
import NewsLetter from "../components/NewsLetter.astro";
import {siteConfig} from "../config";
import ArticleList from "../components/ArticleList.astro";
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
const posts = await getCollection('posts');
posts.sort((a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime());
---
<Layout title="Blog Posts" description="List all files and folders in the directory.">
<Layout title="Blog Posts" description="List all posts on the website.">
<h1 class="title">~/blog</h1>
<div class="content">
<p class="typewriter">Posts from the terminal.</p>
<p class="typewriter">{t('posts.description')}</p>
<div style="margin-top: 2rem;">
<span class="command">ls -la posts/</span>
@ -22,9 +26,9 @@ posts.sort((a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDat
<div style="margin-top: 2rem;">
<p>
<span class="command">cat rss.txt</span>
<span class="command">cat subscribe.txt</span>
<br />
<a href="/rss.xml" style="margin-left: 1rem;">Subscribe to RSS feed</a>
<a href="/rss.xml" style="margin-left: 1rem;">{t('subscribe.rss')}</a>
</p>
{siteConfig.newsletter.enabled && <NewsLetter listmonkInstance={siteConfig.newsletter.listmonk.instanceDomain} listuuid={siteConfig.newsletter.listmonk.listuuid} />}
</div>

View file

@ -10,6 +10,11 @@ import { ExtractFirstImage } from '../../plugins/extract-images';
import AuthorInfo from "../../components/helper/authors/Info.astro";
import TableOfContents from "../../components/TableOfContents.astro";
import "katex/dist/katex.css"
import { getLangFromUrl, useTranslations, useTranslatedPath } from '../../i18n/utils';
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
export async function getStaticPaths() {
const blogEntries = await getCollection('posts');
@ -29,6 +34,9 @@ const author = Array.isArray(entry.data.author) ? entry.data.author : (entry.dat
const wordcount = remarkPluginFrontmatter.wordcount;
const lastUpdated = remarkPluginFrontmatter.lastModified;
const pubDate = new Date(entry.data.pubDate).toISOString().split('T')[0]
const lastUpdatedDate = new Date(lastUpdated).toISOString().split('T')[0]
// Get author data
const authorData = await Promise.all((author).map((singleAuthor) => getEntry(singleAuthor).then(authorEntry => authorEntry?.data)))
const authorInfo = authorData.includes(undefined) ? [{data: siteConfig.defaultAuthor}] : authorData;
@ -56,10 +64,10 @@ const cover = customFeaturedImage || matchedImage_src?.src || firstImageURL || `
<article>
<h1 class="title">{entry.data.title}</h1>
{authorInfo.map((a: any) => <AuthorInfo data={a} />)}
<span class="date">{new Date(entry.data.pubDate).toISOString().split('T')[0]}</span>
<span class="date">(Updated on {new Date(lastUpdated).toISOString().split('T')[0]})</span>
<span class="date">{pubDate}</span>
{pubDate === lastUpdatedDate && <span class="date">({t('article.last_update')} {lastUpdatedDate})</span>}
<span>|</span>
<span class="wordcount">{wordcount.words} words</span>
<span class="wordcount">{wordcount.words} {t('article.word_count')}</span>
{ (cover && cover !== firstImageURL && cover !== `/blog/${slug}/featured.png`) && <Image class="cover" width=720 height=480 src={cover} alt={`cover of ${entry.data.title}`} /> }
{headings.length !== 0 && <TableOfContents headings={headings} />}
{entry.data.summary && <p class="summary">{entry.data.summary}</p> }
@ -70,8 +78,8 @@ const cover = customFeaturedImage || matchedImage_src?.src || firstImageURL || `
<div class="extra-post" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
<ReplyViaEmail title={entry.data.title} email={authorInfo[0].email} />
<br>
<a href="/blog">&larr; Back to posts</a>
{!noscript && <h2>Comments</h2> <Comments />}
<a href="/blog">&larr; {t('article.back_to_posts')}</a>
{!noscript && <h2>{t('article.comments')}</h2> <Comments />}
{!noscript &&
<script>
import "katex/dist/contrib/copy-tex.js"