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

@ -1,10 +1,10 @@
import { defineConfig } from 'astro/config'; import {defineConfig} from 'astro/config';
import sitemap from '@astrojs/sitemap'; import sitemap from '@astrojs/sitemap';
import mdx from '@astrojs/mdx'; import mdx from '@astrojs/mdx';
import { remarkWordCount } from './src/plugins/remark/wordcount.js'; import {remarkWordCount} from './src/plugins/remark/wordcount.js';
import cloudflare from '@astrojs/cloudflare'; import cloudflare from '@astrojs/cloudflare';
import remarkMath from "remark-math"; import remarkMath from "remark-math";
@ -14,28 +14,33 @@ import partytown from '@astrojs/partytown';
import {remarkModifiedTime} from "./src/plugins/remark/modified-time.mjs"; import {remarkModifiedTime} from "./src/plugins/remark/modified-time.mjs";
export default defineConfig({ export default defineConfig({
site: 'https://terminal-blog.example.com', site: 'https://terminal-blog.example.com',
base: '/', base: '/',
trailingSlash: 'ignore', trailingSlash: 'ignore',
redirects: { redirects: {
// for the old routes still can be accessed // for the old routes still can be accessed
"/post/[...slug]": "/blog/[...slug]" "/post/[...slug]": "/blog/[...slug]"
},
build: {
format: 'directory'
},
markdown: {
shikiConfig: {
theme: 'nord',
wrap: true
}, },
remarkPlugins: [ remarkMath, remarkWordCount, remarkModifiedTime ],
rehypePlugins: [ rehypeKatex ]
},
integrations: [sitemap(), mdx(), partytown()], build: {
format: 'directory'
},
adapter: cloudflare() markdown: {
shikiConfig: {
theme: 'nord',
wrap: true
},
remarkPlugins: [remarkMath, remarkWordCount, remarkModifiedTime],
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 {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 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> <script>
// Get the button // Get the button
let toTopButton = document.getElementById("toTopBtn"); 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) const inputId = Astro.props.listuuid.substring(0,5)
--- ---
<details> <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"> <form method="post" action={`https://${listmonkInstance}/subscription/form`} class="listmonk-form">
<div> <div>
<input type="hidden" name="nonce" /> <input type="hidden" name="nonce" />
<input type="email" name="email" required placeholder="E-mail" /> <input type="email" name="email" required placeholder={t('subscribe.newsletter.email_label')} />
<input type="text" name="name" placeholder="Name (optional)" /> <input type="text" name="name" placeholder={t('subscribe.newsletter.name_label')} />
<input id={inputId} type="checkbox" name="l" checked="checked" value={listuuid} hidden /> <input id={inputId} type="checkbox" name="l" checked="checked" value={listuuid} hidden />
<input type="submit" value="Subscribe" /> <input type="submit" value="Subscribe" />

View file

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

View file

@ -1,5 +1,11 @@
--- ---
import type { MarkdownHeading } from 'astro'; 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 { interface Props {
headings: MarkdownHeading[]; headings: MarkdownHeading[];
@ -34,7 +40,7 @@ function buildHierarchy(headings: MarkdownHeading[]) {
const toc = buildHierarchy(filteredHeadings); const toc = buildHierarchy(filteredHeadings);
--- ---
<details> <details>
<summary>Table of Contents</summary> <summary>{t('article.toc')}</summary>
<ul class="toc-list"> <ul class="toc-list">
{ {
toc.map((heading) => ( 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"> <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="menu-body" id="menu-body">
<div class="dropdown-item" data-theme="auto">System</div> <div class="dropdown-item" data-theme="auto">{t('widget.theme_mode.auto')}</div>
<div class="dropdown-item" data-theme="dark">Dark</div> <div class="dropdown-item" data-theme="dark">{t('widget.theme_mode.dark')}</div>
<div class="dropdown-item" data-theme="light">Light</div> <div class="dropdown-item" data-theme="light">{t('widget.theme_mode.light')}</div>
</div> </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"> <div class="theme-switcher">
<input type="checkbox" id="theme-toggle" class="theme-toggle" /> <input type="checkbox" id="theme-toggle" class="theme-toggle" />
<label for="theme-toggle" class="theme-label"> <label for="theme-toggle" class="theme-label">
<span>Theme</span> <span>{t('widget.theme_mode')}</span>
</label> </label>
</div> </div>
<style> <style>

View file

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

View file

@ -1,6 +1,11 @@
--- ---
import { encrypt } from '../../plugins/encrypt'; import { encrypt } from '../../plugins/encrypt';
import {siteConfig} from "../../config"; 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 { interface Props {
password?: string; password?: string;
@ -20,10 +25,10 @@ const { encryptedData, iv } = encrypt(content, password);
<div class="encrypted-content" data-encrypted={encryptedData} data-iv={iv}> <div class="encrypted-content" data-encrypted={encryptedData} data-iv={iv}>
<div class="password-form"> <div class="password-form">
<p>This content is protected. Enter the password to view it:</p> <p>{t('component.protected_content.tip')}</p>
<div> <div>
<input type="password" class="decrypt-password" title="password" /> <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> </div>
<div class="content-container hidden"></div> <div class="content-container hidden"></div>

View file

@ -1,19 +1,65 @@
import { i18n } from "astro:config/client"
export const languages = { export const languages = {
en: 'English', en: 'English',
zh_hans: '中文(简体)', zh_hans: '中文(简体)',
}; };
export const defaultLang = 'en'; export const defaultLang: string = i18n?.defaultLocale || 'en';
export const showDefaultLang = false; export const showDefaultLang = false;
export const ui = { export const ui = {
en: { en: {
'nav.home': 'Home', 'nav.home': 'Home',
'nav.about': 'About', 'nav.blog': 'Blog',
'nav.twitter': 'Twitter', '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: { zh_hans: {
'nav.home': '首页', '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; } as const;

View file

@ -4,16 +4,20 @@ import { getCollection } from 'astro:content';
import NewsLetter from "../components/NewsLetter.astro"; import NewsLetter from "../components/NewsLetter.astro";
import {siteConfig} from "../config"; import {siteConfig} from "../config";
import ArticleList from "../components/ArticleList.astro"; 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'); const posts = await getCollection('posts');
posts.sort((a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()); 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> <h1 class="title">~/blog</h1>
<div class="content"> <div class="content">
<p class="typewriter">Posts from the terminal.</p> <p class="typewriter">{t('posts.description')}</p>
<div style="margin-top: 2rem;"> <div style="margin-top: 2rem;">
<span class="command">ls -la posts/</span> <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;"> <div style="margin-top: 2rem;">
<p> <p>
<span class="command">cat rss.txt</span> <span class="command">cat subscribe.txt</span>
<br /> <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> </p>
{siteConfig.newsletter.enabled && <NewsLetter listmonkInstance={siteConfig.newsletter.listmonk.instanceDomain} listuuid={siteConfig.newsletter.listmonk.listuuid} />} {siteConfig.newsletter.enabled && <NewsLetter listmonkInstance={siteConfig.newsletter.listmonk.instanceDomain} listuuid={siteConfig.newsletter.listmonk.listuuid} />}
</div> </div>

View file

@ -10,6 +10,11 @@ import { ExtractFirstImage } from '../../plugins/extract-images';
import AuthorInfo from "../../components/helper/authors/Info.astro"; import AuthorInfo from "../../components/helper/authors/Info.astro";
import TableOfContents from "../../components/TableOfContents.astro"; import TableOfContents from "../../components/TableOfContents.astro";
import "katex/dist/katex.css" 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() { export async function getStaticPaths() {
const blogEntries = await getCollection('posts'); 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 wordcount = remarkPluginFrontmatter.wordcount;
const lastUpdated = remarkPluginFrontmatter.lastModified; 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 // Get author data
const authorData = await Promise.all((author).map((singleAuthor) => getEntry(singleAuthor).then(authorEntry => authorEntry?.data))) const authorData = await Promise.all((author).map((singleAuthor) => getEntry(singleAuthor).then(authorEntry => authorEntry?.data)))
const authorInfo = authorData.includes(undefined) ? [{data: siteConfig.defaultAuthor}] : authorData; const authorInfo = authorData.includes(undefined) ? [{data: siteConfig.defaultAuthor}] : authorData;
@ -56,10 +64,10 @@ const cover = customFeaturedImage || matchedImage_src?.src || firstImageURL || `
<article> <article>
<h1 class="title">{entry.data.title}</h1> <h1 class="title">{entry.data.title}</h1>
{authorInfo.map((a: any) => <AuthorInfo data={a} />)} {authorInfo.map((a: any) => <AuthorInfo data={a} />)}
<span class="date">{new Date(entry.data.pubDate).toISOString().split('T')[0]}</span> <span class="date">{pubDate}</span>
<span class="date">(Updated on {new Date(lastUpdated).toISOString().split('T')[0]})</span> {pubDate === lastUpdatedDate && <span class="date">({t('article.last_update')} {lastUpdatedDate})</span>}
<span>|</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}`} /> } { (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} />} {headings.length !== 0 && <TableOfContents headings={headings} />}
{entry.data.summary && <p class="summary">{entry.data.summary}</p> } {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;"> <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} /> <ReplyViaEmail title={entry.data.title} email={authorInfo[0].email} />
<br> <br>
<a href="/blog">&larr; Back to posts</a> <a href="/blog">&larr; {t('article.back_to_posts')}</a>
{!noscript && <h2>Comments</h2> <Comments />} {!noscript && <h2>{t('article.comments')}</h2> <Comments />}
{!noscript && {!noscript &&
<script> <script>
import "katex/dist/contrib/copy-tex.js" import "katex/dist/contrib/copy-tex.js"