@hutusi/amytis 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GEMINI.md +6 -2
- package/TODO.md +11 -15
- package/bun.lock +3 -0
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/20.md +16 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
- package/content/posts/multimedia-showcase/index.mdx +261 -0
- package/content/privacy.mdx +32 -0
- package/content/privacy.zh.mdx +32 -0
- package/docs/ARCHITECTURE.md +8 -0
- package/package.json +2 -1
- package/scripts/copy-assets.ts +1 -1
- package/site.config.ts +126 -44
- package/src/app/[slug]/page.tsx +0 -10
- package/src/app/archive/page.tsx +38 -10
- package/src/app/books/[slug]/page.tsx +18 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +20 -3
- package/src/app/layout.tsx +47 -21
- package/src/app/page.tsx +135 -72
- package/src/app/posts/[slug]/page.tsx +6 -12
- package/src/app/series/[slug]/page.tsx +18 -0
- package/src/app/subscribe/page.tsx +17 -0
- package/src/app/tags/[tag]/page.tsx +9 -26
- package/src/app/tags/page.tsx +3 -8
- package/src/components/AuthorCard.tsx +43 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/Footer.tsx +35 -26
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/Navbar.tsx +31 -8
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +24 -4
- package/src/components/SelectedBooksSection.tsx +12 -6
- package/src/components/ShareBar.tsx +115 -0
- package/src/components/SimpleLayoutHeader.tsx +5 -14
- package/src/components/SubscribePage.tsx +298 -0
- package/src/components/TagContentTabs.tsx +103 -0
- package/src/components/TagPageHeader.tsx +7 -13
- package/src/components/TagSidebar.tsx +142 -0
- package/src/components/TagsIndexClient.tsx +156 -0
- package/src/hooks/useScrollY.ts +41 -0
- package/src/i18n/translations.ts +84 -0
- package/src/layouts/PostLayout.tsx +33 -6
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +71 -15
- package/src/components/TableOfContents.tsx +0 -158
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { LuSearch, LuX } from 'react-icons/lu';
|
|
6
|
+
import { useLanguage } from './LanguageProvider';
|
|
7
|
+
|
|
8
|
+
interface TagsIndexClientProps {
|
|
9
|
+
tags: Record<string, number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SortMode = 'popular' | 'alpha';
|
|
13
|
+
|
|
14
|
+
function getTagClasses(count: number, min: number, max: number): string {
|
|
15
|
+
const ratio = max === min ? 0.5 : (count - min) / (max - min);
|
|
16
|
+
if (ratio >= 0.8) return 'text-xl font-bold px-5 py-2.5';
|
|
17
|
+
if (ratio >= 0.6) return 'text-lg font-semibold px-5 py-2';
|
|
18
|
+
if (ratio >= 0.4) return 'text-base font-medium px-4 py-2';
|
|
19
|
+
if (ratio >= 0.2) return 'text-sm px-3.5 py-1.5';
|
|
20
|
+
return 'text-xs px-3 py-1.5';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function TagLink({ tag, count, min, max }: { tag: string; count: number; min: number; max: number }) {
|
|
24
|
+
return (
|
|
25
|
+
<Link
|
|
26
|
+
href={`/tags/${encodeURIComponent(tag)}`}
|
|
27
|
+
className={`group inline-flex items-baseline gap-1.5 rounded-xl border border-muted/20 bg-muted/5 hover:bg-background hover:border-accent hover:shadow-md hover:shadow-accent/5 no-underline transition-all duration-200 ${getTagClasses(count, min, max)}`}
|
|
28
|
+
>
|
|
29
|
+
<span className="text-foreground group-hover:text-accent transition-colors">{tag}</span>
|
|
30
|
+
<span className="font-mono text-muted/50 group-hover:text-accent/50 transition-colors" style={{ fontSize: '0.7em' }}>{count}</span>
|
|
31
|
+
</Link>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function TagsIndexClient({ tags }: TagsIndexClientProps) {
|
|
36
|
+
const { t, tWith } = useLanguage();
|
|
37
|
+
const [filter, setFilter] = useState('');
|
|
38
|
+
const [sort, setSort] = useState<SortMode>('popular');
|
|
39
|
+
|
|
40
|
+
const total = Object.keys(tags).length;
|
|
41
|
+
const allEntries = Object.entries(tags);
|
|
42
|
+
const counts = allEntries.map(([, c]) => c);
|
|
43
|
+
const min = Math.min(...counts);
|
|
44
|
+
const max = Math.max(...counts);
|
|
45
|
+
|
|
46
|
+
const filtered = allEntries
|
|
47
|
+
.filter(([tag]) => !filter || tag.toLowerCase().includes(filter.toLowerCase()))
|
|
48
|
+
.sort((a, b) =>
|
|
49
|
+
sort === 'popular'
|
|
50
|
+
? b[1] - a[1]
|
|
51
|
+
: a[0].localeCompare(b[0])
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Group by first letter for A-Z mode
|
|
55
|
+
const letterGroups = sort === 'alpha'
|
|
56
|
+
? filtered.reduce<Record<string, [string, number][]>>((acc, entry) => {
|
|
57
|
+
const letter = /^[a-zA-Z]/.test(entry[0]) ? entry[0][0].toUpperCase() : '#';
|
|
58
|
+
if (!acc[letter]) acc[letter] = [];
|
|
59
|
+
acc[letter].push(entry);
|
|
60
|
+
return acc;
|
|
61
|
+
}, {})
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
const sortedLetters = letterGroups
|
|
65
|
+
? Object.keys(letterGroups).sort((a, b) => a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b))
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div>
|
|
70
|
+
{/* Controls */}
|
|
71
|
+
<div className="flex flex-col sm:flex-row gap-3 mb-10">
|
|
72
|
+
<div className="relative flex-1 max-w-sm">
|
|
73
|
+
<LuSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted/50 pointer-events-none" />
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
value={filter}
|
|
77
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
78
|
+
placeholder="Filter tags…"
|
|
79
|
+
aria-label={t('filter_tags')}
|
|
80
|
+
className="w-full pl-9 pr-8 py-2 text-sm bg-muted/5 border border-muted/15 rounded-lg outline-none focus:border-accent/40 text-foreground placeholder:text-muted/40 transition-colors"
|
|
81
|
+
/>
|
|
82
|
+
{filter && (
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => setFilter('')}
|
|
85
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted transition-colors p-0.5 rounded"
|
|
86
|
+
aria-label="Clear filter"
|
|
87
|
+
>
|
|
88
|
+
<LuX className="w-3.5 h-3.5" />
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className="flex rounded-lg border border-muted/15 overflow-hidden text-xs font-sans font-semibold self-start">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => setSort('popular')}
|
|
97
|
+
aria-pressed={sort === 'popular'}
|
|
98
|
+
className={`px-4 py-2 transition-colors ${sort === 'popular' ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground hover:bg-muted/5'}`}
|
|
99
|
+
>
|
|
100
|
+
{t('sort_popular')}
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => setSort('alpha')}
|
|
105
|
+
aria-pressed={sort === 'alpha'}
|
|
106
|
+
className={`px-4 py-2 border-l border-muted/15 transition-colors ${sort === 'alpha' ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground hover:bg-muted/5'}`}
|
|
107
|
+
>
|
|
108
|
+
{t('sort_az')}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Result count when filtering */}
|
|
114
|
+
{filter && (
|
|
115
|
+
<p className="text-xs font-mono text-muted mb-6">
|
|
116
|
+
{tWith('tags_count', { shown: filtered.length, total })}
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Popular mode: flat size-scaled cloud */}
|
|
121
|
+
{sort === 'popular' && (
|
|
122
|
+
<div className="flex flex-wrap gap-3 items-baseline">
|
|
123
|
+
{filtered.map(([tag, count]) => (
|
|
124
|
+
<TagLink key={tag} tag={tag} count={count} min={min} max={max} />
|
|
125
|
+
))}
|
|
126
|
+
{filtered.length === 0 && (
|
|
127
|
+
<p className="text-sm text-muted italic">{tWith('tags_no_match', { filter })}</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* A-Z mode: grouped under letter section headers */}
|
|
133
|
+
{sort === 'alpha' && sortedLetters && (
|
|
134
|
+
<div>
|
|
135
|
+
{sortedLetters.length === 0 ? (
|
|
136
|
+
<p className="text-sm text-muted italic">{tWith('tags_no_match', { filter })}</p>
|
|
137
|
+
) : (
|
|
138
|
+
sortedLetters.map((letter, i) => (
|
|
139
|
+
<div key={letter} className={i > 0 ? 'mt-10' : ''}>
|
|
140
|
+
<div className="flex items-center gap-3 mb-4">
|
|
141
|
+
<span className="text-xs font-mono font-bold text-muted/40 w-4">{letter}</span>
|
|
142
|
+
<div className="flex-1 h-px bg-muted/10" />
|
|
143
|
+
</div>
|
|
144
|
+
<div className="flex flex-wrap gap-3 items-baseline">
|
|
145
|
+
{letterGroups![letter].map(([tag, count]) => (
|
|
146
|
+
<TagLink key={tag} tag={tag} count={count} min={min} max={max} />
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
))
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Module-level subscriber set — only one DOM scroll listener exists
|
|
7
|
+
* regardless of how many components call this hook.
|
|
8
|
+
*/
|
|
9
|
+
const listeners = new Set<(y: number) => void>();
|
|
10
|
+
|
|
11
|
+
function onScroll() {
|
|
12
|
+
const y = window.scrollY;
|
|
13
|
+
listeners.forEach(fn => fn(y));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the current window.scrollY, updating on every scroll event.
|
|
18
|
+
* A single passive scroll listener is shared across all consumers.
|
|
19
|
+
*/
|
|
20
|
+
export function useScrollY(): number {
|
|
21
|
+
const [scrollY, setScrollY] = useState(0);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (listeners.size === 0) {
|
|
25
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
26
|
+
}
|
|
27
|
+
listeners.add(setScrollY);
|
|
28
|
+
// Sync on mount via RAF to avoid cascading render error
|
|
29
|
+
const rafId = requestAnimationFrame(() => setScrollY(window.scrollY));
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
cancelAnimationFrame(rafId);
|
|
33
|
+
listeners.delete(setScrollY);
|
|
34
|
+
if (listeners.size === 0) {
|
|
35
|
+
window.removeEventListener('scroll', onScroll);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return scrollY;
|
|
41
|
+
}
|
package/src/i18n/translations.ts
CHANGED
|
@@ -25,8 +25,12 @@ export const translations = {
|
|
|
25
25
|
categories: "Categories",
|
|
26
26
|
articles: "Articles",
|
|
27
27
|
posts: "Posts",
|
|
28
|
+
links: "Links",
|
|
28
29
|
explore: "Explore",
|
|
29
30
|
connect: "Connect",
|
|
31
|
+
rss_feed: "RSS Feed",
|
|
32
|
+
privacy: "Privacy",
|
|
33
|
+
built_with: "Built with Amytis",
|
|
30
34
|
on_this_page: "On this page",
|
|
31
35
|
back_to_top: "Back to top",
|
|
32
36
|
archive_subtitle: "{count} posts across {years} years.",
|
|
@@ -75,6 +79,44 @@ export const translations = {
|
|
|
75
79
|
search_tip_phrase: "Quoted string for exact matching (\" \" = Exact match)",
|
|
76
80
|
search_tip_and: "Use spaces to combine keywords (Space = AND)",
|
|
77
81
|
search_tip_exclude: "Exclude a term (- = Exclude)",
|
|
82
|
+
discuss_post: "Discuss this post",
|
|
83
|
+
share_post: "Share",
|
|
84
|
+
copy_link: "Copy link",
|
|
85
|
+
link_copied: "Copied!",
|
|
86
|
+
flow_notes: "Flow Notes",
|
|
87
|
+
tag_post_count: "{count} posts",
|
|
88
|
+
tag_post_count_one: "1 post",
|
|
89
|
+
tag_flow_count: "{count} flow notes",
|
|
90
|
+
tag_flow_count_one: "1 flow note",
|
|
91
|
+
subscribe: "Subscribe",
|
|
92
|
+
subscribe_subtitle: "Stay updated with new posts and notes via your preferred channel.",
|
|
93
|
+
rss_readers: "RSS Readers",
|
|
94
|
+
rss_description: "Subscribe with any RSS reader for automatic updates when new content is published.",
|
|
95
|
+
email_newsletter: "Email Newsletter",
|
|
96
|
+
email_newsletter_description: "Get new posts delivered directly to your inbox.",
|
|
97
|
+
telegram_channel: "Telegram",
|
|
98
|
+
telegram_channel_description: "Instant updates via Telegram channel.",
|
|
99
|
+
wechat_official: "WeChat Official Account",
|
|
100
|
+
wechat_description: "Follow on WeChat for updates.",
|
|
101
|
+
scan_qr_code: "Scan to follow",
|
|
102
|
+
copy_feed_url: "Copy feed URL",
|
|
103
|
+
feed_url_copied: "Copied!",
|
|
104
|
+
join_channel: "Join Channel",
|
|
105
|
+
subscribe_on_substack: "Subscribe on Substack",
|
|
106
|
+
subscribe_via_email: "Subscribe via Email",
|
|
107
|
+
social_connections: "Social",
|
|
108
|
+
older: "Older",
|
|
109
|
+
newer: "Newer",
|
|
110
|
+
tab_all: "All",
|
|
111
|
+
post_navigation: "Post navigation",
|
|
112
|
+
filter_tags: "Filter tags",
|
|
113
|
+
no_tags_found: "No tags found",
|
|
114
|
+
more_tags: "+ {count} more",
|
|
115
|
+
collapse_tags: "Show less",
|
|
116
|
+
sort_popular: "Popular",
|
|
117
|
+
sort_az: "A–Z",
|
|
118
|
+
tags_count: "{shown} / {total} tags",
|
|
119
|
+
tags_no_match: "No tags match \"{filter}\"",
|
|
78
120
|
},
|
|
79
121
|
zh: {
|
|
80
122
|
home: "首页",
|
|
@@ -102,8 +144,12 @@ export const translations = {
|
|
|
102
144
|
categories: "分类",
|
|
103
145
|
articles: "文章",
|
|
104
146
|
posts: "文章",
|
|
147
|
+
links: "链接",
|
|
105
148
|
explore: "探索",
|
|
106
149
|
connect: "连接",
|
|
150
|
+
rss_feed: "RSS 订阅",
|
|
151
|
+
privacy: "隐私政策",
|
|
152
|
+
built_with: "基于 Amytis 构建",
|
|
107
153
|
on_this_page: "本页目录",
|
|
108
154
|
back_to_top: "返回顶部",
|
|
109
155
|
archive_subtitle: "横跨 {years} 年,共 {count} 篇文章。",
|
|
@@ -152,6 +198,44 @@ export const translations = {
|
|
|
152
198
|
search_tip_phrase: "加引号精确匹配短语(\" \" 表示精确匹配)",
|
|
153
199
|
search_tip_and: "使用空格组合关键词(空格表示 AND)",
|
|
154
200
|
search_tip_exclude: "排除关键词(- 表示排除)",
|
|
201
|
+
discuss_post: "讨论这篇文章",
|
|
202
|
+
share_post: "分享",
|
|
203
|
+
copy_link: "复制链接",
|
|
204
|
+
link_copied: "已复制",
|
|
205
|
+
flow_notes: "随笔",
|
|
206
|
+
tag_post_count: "{count} 篇文章",
|
|
207
|
+
tag_post_count_one: "1 篇文章",
|
|
208
|
+
tag_flow_count: "{count} 条随笔",
|
|
209
|
+
tag_flow_count_one: "1 条随笔",
|
|
210
|
+
subscribe: "订阅",
|
|
211
|
+
subscribe_subtitle: "通过您喜爱的方式订阅,及时获取新文章和随笔。",
|
|
212
|
+
rss_readers: "RSS 阅读器",
|
|
213
|
+
rss_description: "通过 RSS 阅读器订阅,发布新内容时自动获取更新。",
|
|
214
|
+
email_newsletter: "邮件订阅",
|
|
215
|
+
email_newsletter_description: "将新文章直接发送到您的邮箱。",
|
|
216
|
+
telegram_channel: "Telegram 频道",
|
|
217
|
+
telegram_channel_description: "通过 Telegram 频道获取即时更新。",
|
|
218
|
+
wechat_official: "微信公众号",
|
|
219
|
+
wechat_description: "关注微信公众号获取更新。",
|
|
220
|
+
scan_qr_code: "扫码关注",
|
|
221
|
+
copy_feed_url: "复制订阅链接",
|
|
222
|
+
feed_url_copied: "已复制!",
|
|
223
|
+
join_channel: "加入频道",
|
|
224
|
+
subscribe_on_substack: "在 Substack 订阅",
|
|
225
|
+
subscribe_via_email: "邮件订阅",
|
|
226
|
+
social_connections: "社交媒体",
|
|
227
|
+
older: "更早",
|
|
228
|
+
newer: "更新",
|
|
229
|
+
tab_all: "全部",
|
|
230
|
+
post_navigation: "文章导航",
|
|
231
|
+
filter_tags: "筛选标签",
|
|
232
|
+
no_tags_found: "未找到标签",
|
|
233
|
+
more_tags: "还有 {count} 个",
|
|
234
|
+
collapse_tags: "收起",
|
|
235
|
+
sort_popular: "热门",
|
|
236
|
+
sort_az: "A–Z",
|
|
237
|
+
tags_count: "{shown} / {total} 个标签",
|
|
238
|
+
tags_no_match: "未找到匹配\"{filter}\"的标签",
|
|
155
239
|
},
|
|
156
240
|
};
|
|
157
241
|
|
|
@@ -8,6 +8,9 @@ import Comments from '@/components/Comments';
|
|
|
8
8
|
import ExternalLinks from '@/components/ExternalLinks';
|
|
9
9
|
import Tag from '@/components/Tag';
|
|
10
10
|
import ReadingProgressBar from '@/components/ReadingProgressBar';
|
|
11
|
+
import PostNavigation from '@/components/PostNavigation';
|
|
12
|
+
import AuthorCard from '@/components/AuthorCard';
|
|
13
|
+
import ShareBar from '@/components/ShareBar';
|
|
11
14
|
import { siteConfig } from '../../site.config';
|
|
12
15
|
import { t } from '@/lib/i18n';
|
|
13
16
|
|
|
@@ -16,15 +19,18 @@ interface PostLayoutProps {
|
|
|
16
19
|
relatedPosts?: PostData[];
|
|
17
20
|
seriesPosts?: PostData[];
|
|
18
21
|
seriesTitle?: string;
|
|
22
|
+
prevPost?: PostData | null;
|
|
23
|
+
nextPost?: PostData | null;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitle }: PostLayoutProps) {
|
|
22
|
-
const showToc = siteConfig.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
|
|
26
|
+
export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitle, prevPost, nextPost }: PostLayoutProps) {
|
|
27
|
+
const showToc = siteConfig.posts?.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
|
|
23
28
|
const hasSeries = !!(post.series && seriesPosts && seriesPosts.length > 0);
|
|
24
29
|
const showSidebar = showToc || hasSeries;
|
|
30
|
+
const postUrl = `${siteConfig.baseUrl}/posts/${post.slug}`;
|
|
25
31
|
|
|
26
32
|
return (
|
|
27
|
-
<div className=
|
|
33
|
+
<div className="layout-container">
|
|
28
34
|
<ReadingProgressBar />
|
|
29
35
|
<div className={showSidebar
|
|
30
36
|
? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
|
|
@@ -38,11 +44,13 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
38
44
|
posts={hasSeries ? seriesPosts : undefined}
|
|
39
45
|
currentSlug={post.slug}
|
|
40
46
|
headings={showToc ? post.headings : []}
|
|
47
|
+
shareUrl={postUrl}
|
|
48
|
+
shareTitle={post.title}
|
|
41
49
|
/>
|
|
42
50
|
)}
|
|
43
51
|
|
|
44
|
-
<article className="min-w-0 max-w-3xl">
|
|
45
|
-
<header className="mb-16 border-b border-muted/10 pb-
|
|
52
|
+
<article className="min-w-0 max-w-3xl mx-auto">
|
|
53
|
+
<header className="mb-16 border-b border-muted/10 pb-8">
|
|
46
54
|
{post.draft && (
|
|
47
55
|
<div className="mb-4">
|
|
48
56
|
<span className="text-xs font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded tracking-widest inline-block">
|
|
@@ -104,13 +112,32 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
104
112
|
|
|
105
113
|
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
106
114
|
|
|
115
|
+
{post.tags && post.tags.length > 0 && (
|
|
116
|
+
<div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
|
|
117
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mr-1">{t('tags')}</span>
|
|
118
|
+
{post.tags.map((tag) => (
|
|
119
|
+
<Tag key={tag} tag={tag} variant="default" />
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
107
124
|
{post.externalLinks && post.externalLinks.length > 0 && (
|
|
108
125
|
<ExternalLinks links={post.externalLinks} />
|
|
109
126
|
)}
|
|
110
127
|
|
|
111
|
-
<
|
|
128
|
+
<ShareBar
|
|
129
|
+
url={postUrl}
|
|
130
|
+
title={post.title}
|
|
131
|
+
className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
<AuthorCard authors={post.authors} />
|
|
135
|
+
|
|
136
|
+
<PostNavigation prev={prevPost ?? null} next={nextPost ?? null} />
|
|
112
137
|
|
|
113
138
|
<Comments slug={post.slug} />
|
|
139
|
+
|
|
140
|
+
<RelatedPosts posts={relatedPosts || []} />
|
|
114
141
|
</article>
|
|
115
142
|
</div>
|
|
116
143
|
</div>
|
|
@@ -1,31 +1,69 @@
|
|
|
1
1
|
import { PostData } from '@/lib/markdown';
|
|
2
2
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
3
3
|
import SimpleLayoutHeader from '@/components/SimpleLayoutHeader';
|
|
4
|
+
import LocaleSwitch from '@/components/LocaleSwitch';
|
|
5
|
+
import PostSidebar from '@/components/PostSidebar';
|
|
4
6
|
import { TranslationKey } from '@/i18n/translations';
|
|
7
|
+
import { siteConfig } from '../../site.config';
|
|
5
8
|
|
|
6
9
|
interface SimpleLayoutProps {
|
|
7
10
|
post: PostData;
|
|
8
11
|
titleKey?: TranslationKey;
|
|
9
12
|
subtitleKey?: TranslationKey;
|
|
10
|
-
titleOverride?: string | Record<string, string>;
|
|
11
|
-
subtitleOverride?: string | Record<string, string>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export default function SimpleLayout({ post, titleKey, subtitleKey
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
/>
|
|
15
|
+
export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayoutProps) {
|
|
16
|
+
const defaultLocale = siteConfig.i18n.defaultLocale;
|
|
17
|
+
const localeEntries = Object.entries(post.contentLocales ?? {});
|
|
18
|
+
const showToc = siteConfig.posts?.toc !== false && post.toc !== false && post.headings?.length > 0;
|
|
19
|
+
const localeHeadings = post.contentLocales
|
|
20
|
+
? Object.fromEntries(
|
|
21
|
+
Object.entries(post.contentLocales)
|
|
22
|
+
.filter(([, data]) => data.headings && data.headings.length > 0)
|
|
23
|
+
.map(([locale, data]) => [locale, data.headings!])
|
|
24
|
+
)
|
|
25
|
+
: undefined;
|
|
26
26
|
|
|
27
|
+
const articleContent = (
|
|
28
|
+
<>
|
|
29
|
+
<SimpleLayoutHeader
|
|
30
|
+
title={post.title}
|
|
31
|
+
excerpt={post.excerpt}
|
|
32
|
+
titleKey={titleKey}
|
|
33
|
+
subtitleKey={subtitleKey}
|
|
34
|
+
contentLocales={post.contentLocales}
|
|
35
|
+
/>
|
|
36
|
+
{localeEntries.length > 0 ? (
|
|
37
|
+
<LocaleSwitch>
|
|
38
|
+
<div data-locale={defaultLocale}>
|
|
39
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
40
|
+
</div>
|
|
41
|
+
{localeEntries.map(([locale, data]) => (
|
|
42
|
+
<div key={locale} data-locale={locale} style={{ display: 'none' }}>
|
|
43
|
+
<MarkdownRenderer content={data.content} latex={post.latex} slug={post.slug} />
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</LocaleSwitch>
|
|
47
|
+
) : (
|
|
27
48
|
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
28
|
-
|
|
49
|
+
)}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="layout-main">
|
|
55
|
+
{showToc ? (
|
|
56
|
+
<div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
|
|
57
|
+
<PostSidebar currentSlug={post.slug} headings={post.headings} localeHeadings={localeHeadings} />
|
|
58
|
+
<article className="min-w-0 max-w-3xl">
|
|
59
|
+
{articleContent}
|
|
60
|
+
</article>
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<article className="max-w-3xl mx-auto">
|
|
64
|
+
{articleContent}
|
|
65
|
+
</article>
|
|
66
|
+
)}
|
|
29
67
|
</div>
|
|
30
68
|
);
|
|
31
69
|
}
|
package/src/lib/markdown.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface PostData {
|
|
|
68
68
|
readingTime: string;
|
|
69
69
|
content: string;
|
|
70
70
|
headings: Heading[];
|
|
71
|
+
contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export function calculateReadingTime(content: string): string {
|
|
@@ -238,7 +239,7 @@ export function getAllPosts(): PostData[] {
|
|
|
238
239
|
|
|
239
240
|
if (match) {
|
|
240
241
|
dateFromFileName = match[1];
|
|
241
|
-
if (siteConfig.includeDateInUrl) {
|
|
242
|
+
if (siteConfig.posts?.includeDateInUrl) {
|
|
242
243
|
slug = rawName;
|
|
243
244
|
} else {
|
|
244
245
|
slug = match[2];
|
|
@@ -266,7 +267,7 @@ export function getAllPosts(): PostData[] {
|
|
|
266
267
|
let sDate = undefined;
|
|
267
268
|
if (sMatch) {
|
|
268
269
|
sDate = sMatch[1];
|
|
269
|
-
sSlug = siteConfig.includeDateInUrl ? sRawName : sMatch[2];
|
|
270
|
+
sSlug = siteConfig.posts?.includeDateInUrl ? sRawName : sMatch[2];
|
|
270
271
|
}
|
|
271
272
|
|
|
272
273
|
allPostsData.push(parseMarkdownFile(
|
|
@@ -294,7 +295,7 @@ export function getAllPosts(): PostData[] {
|
|
|
294
295
|
|
|
295
296
|
if (sMatch) {
|
|
296
297
|
sDate = sMatch[1];
|
|
297
|
-
sSlug = siteConfig.includeDateInUrl ? sItem.name : sMatch[2];
|
|
298
|
+
sSlug = siteConfig.posts?.includeDateInUrl ? sItem.name : sMatch[2];
|
|
298
299
|
}
|
|
299
300
|
|
|
300
301
|
allPostsData.push(parseMarkdownFile(
|
|
@@ -338,7 +339,7 @@ export function getAllPosts(): PostData[] {
|
|
|
338
339
|
return false;
|
|
339
340
|
}
|
|
340
341
|
|
|
341
|
-
if (!siteConfig.showFuturePosts) {
|
|
342
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
342
343
|
const postDate = new Date(post.date);
|
|
343
344
|
const now = new Date();
|
|
344
345
|
if (postDate > now) return false;
|
|
@@ -396,7 +397,7 @@ function findPostFile(name: string, targetSlug: string): PostData | null {
|
|
|
396
397
|
export function getPostBySlug(slug: string): PostData | null {
|
|
397
398
|
let post: PostData | null = null;
|
|
398
399
|
|
|
399
|
-
if (siteConfig.includeDateInUrl) {
|
|
400
|
+
if (siteConfig.posts?.includeDateInUrl) {
|
|
400
401
|
post = findPostFile(slug, slug);
|
|
401
402
|
} else {
|
|
402
403
|
post = findPostFile(slug, slug);
|
|
@@ -445,7 +446,7 @@ export function getPostBySlug(slug: string): PostData | null {
|
|
|
445
446
|
return null;
|
|
446
447
|
}
|
|
447
448
|
|
|
448
|
-
if (!siteConfig.showFuturePosts) {
|
|
449
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
449
450
|
const postDate = new Date(post.date);
|
|
450
451
|
const now = new Date();
|
|
451
452
|
if (postDate > now) return null;
|
|
@@ -453,18 +454,53 @@ export function getPostBySlug(slug: string): PostData | null {
|
|
|
453
454
|
return post;
|
|
454
455
|
}
|
|
455
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Load the content and frontmatter of a locale variant file, e.g. about.zh.mdx.
|
|
459
|
+
* Returns null when the file does not exist or cannot be parsed.
|
|
460
|
+
*/
|
|
461
|
+
function loadLocaleContent(slug: string, locale: string): { content: string; title?: string; excerpt?: string; headings?: Heading[] } | null {
|
|
462
|
+
for (const ext of ['.mdx', '.md']) {
|
|
463
|
+
const filePath = path.join(pagesDirectory, `${slug}.${locale}${ext}`);
|
|
464
|
+
if (fs.existsSync(filePath)) {
|
|
465
|
+
try {
|
|
466
|
+
const { data, content } = matter(fs.readFileSync(filePath, 'utf8'));
|
|
467
|
+
const body = content.replace(/^\s*#\s+[^\n]+/, '').trim();
|
|
468
|
+
return {
|
|
469
|
+
content: body,
|
|
470
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
471
|
+
excerpt: typeof data.excerpt === 'string' ? data.excerpt : undefined,
|
|
472
|
+
headings: getHeadings(body),
|
|
473
|
+
};
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Collect contentLocales for all non-default locales that have a variant file.
|
|
484
|
+
*/
|
|
485
|
+
function attachContentLocales(page: PostData, slug: string): PostData {
|
|
486
|
+
const defaultLocale = siteConfig.i18n.defaultLocale;
|
|
487
|
+
const otherLocales = siteConfig.i18n.locales.filter(l => l !== defaultLocale);
|
|
488
|
+
const contentLocales: NonNullable<PostData['contentLocales']> = {};
|
|
489
|
+
for (const locale of otherLocales) {
|
|
490
|
+
const localeData = loadLocaleContent(slug, locale);
|
|
491
|
+
if (localeData !== null) contentLocales[locale] = localeData;
|
|
492
|
+
}
|
|
493
|
+
return Object.keys(contentLocales).length > 0 ? { ...page, contentLocales } : page;
|
|
494
|
+
}
|
|
495
|
+
|
|
456
496
|
export function getPageBySlug(slug: string): PostData | null {
|
|
457
497
|
try {
|
|
458
498
|
let fullPath = path.join(pagesDirectory, `${slug}.mdx`);
|
|
459
499
|
if (!fs.existsSync(fullPath)) {
|
|
460
500
|
fullPath = path.join(pagesDirectory, `${slug}.md`);
|
|
461
501
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return parseMarkdownFile(fullPath, slug);
|
|
502
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
503
|
+
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
468
504
|
} catch {
|
|
469
505
|
return null;
|
|
470
506
|
}
|
|
@@ -473,11 +509,21 @@ export function getPageBySlug(slug: string): PostData | null {
|
|
|
473
509
|
export function getAllPages(): PostData[] {
|
|
474
510
|
const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
|
|
475
511
|
return items
|
|
476
|
-
.filter(item =>
|
|
512
|
+
.filter(item => {
|
|
513
|
+
if (!item.isFile()) return false;
|
|
514
|
+
if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
|
|
515
|
+
// Exclude locale variant files (e.g. about.zh.mdx, about.en.mdx) — they are not standalone routes
|
|
516
|
+
const base = item.name.replace(/\.mdx?$/, '');
|
|
517
|
+
const parts = base.split('.');
|
|
518
|
+
if (parts.length > 1 && siteConfig.i18n.locales.includes(parts[parts.length - 1])) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
522
|
+
})
|
|
477
523
|
.map(item => {
|
|
478
524
|
const slug = item.name.replace(/\.mdx?$/, '');
|
|
479
525
|
const fullPath = path.join(pagesDirectory, item.name);
|
|
480
|
-
return parseMarkdownFile(fullPath, slug);
|
|
526
|
+
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
481
527
|
});
|
|
482
528
|
}
|
|
483
529
|
|
|
@@ -658,6 +704,16 @@ export function getFeaturedPosts(): PostData[] {
|
|
|
658
704
|
return allPosts.filter(post => post.featured);
|
|
659
705
|
}
|
|
660
706
|
|
|
707
|
+
export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
|
|
708
|
+
const allPosts = getAllPosts(); // sorted desc by date (newest first)
|
|
709
|
+
const index = allPosts.findIndex(p => p.slug === slug);
|
|
710
|
+
if (index === -1) return { prev: null, next: null };
|
|
711
|
+
return {
|
|
712
|
+
prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
|
|
713
|
+
next: index > 0 ? allPosts[index - 1] : null, // newer post
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
661
717
|
export function getFeaturedSeries(): Record<string, PostData[]> {
|
|
662
718
|
const allSeries = getAllSeries();
|
|
663
719
|
const featuredSeries: Record<string, PostData[]> = {};
|
|
@@ -1001,7 +1057,7 @@ export function getAllFlows(): FlowData[] {
|
|
|
1001
1057
|
return flows
|
|
1002
1058
|
.filter(flow => {
|
|
1003
1059
|
if (process.env.NODE_ENV === 'production' && flow.draft) return false;
|
|
1004
|
-
if (!siteConfig.showFuturePosts) {
|
|
1060
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
1005
1061
|
const flowDate = new Date(flow.date);
|
|
1006
1062
|
const now = new Date();
|
|
1007
1063
|
if (flowDate > now) return false;
|