@hutusi/amytis 1.12.0 → 1.14.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/CHANGELOG.md +29 -0
- package/GEMINI.md +9 -1
- package/README.md +26 -17
- package/README.zh.md +180 -100
- package/bun.lock +78 -74
- package/content/books/notes-on-thinking/cost-of-certainty.mdx +9 -0
- package/content/books/notes-on-thinking/index.mdx +16 -0
- package/content/books/notes-on-thinking/mental-models.mdx +9 -0
- package/content/books/the-pragmatic-writer/finding-your-voice.mdx +9 -0
- package/content/books/the-pragmatic-writer/index.mdx +18 -0
- package/content/books/the-pragmatic-writer/the-editing-loop.mdx +9 -0
- package/content/books/the-pragmatic-writer/why-writing-matters.mdx +9 -0
- package/content/flows/2026/03/01.md +9 -0
- package/content/flows/2026/03/03.md +9 -0
- package/content/flows/2026/03/05.md +10 -0
- package/content/flows/2026/03/07.md +11 -0
- package/content/posts/images/vibrant-waves.jpg +0 -0
- package/content/posts/welcome-to-amytis.mdx +3 -0
- package/content/series/markdown-showcase/index.mdx +2 -1
- package/content/series/markdown-showcase/mathematical-notation.mdx +8 -4
- package/content/series/markdown-showcase/syntax-highlighting.mdx +9 -5
- package/content/series/markdown-showcase/visuals-and-diagrams.mdx +8 -4
- package/content/{posts → series/markdown-showcase}//344/270/255/346/226/207/346/265/213/350/257/225/346/226/207/347/253/240.mdx +12 -7
- package/content/series/modern-web-dev/index.mdx +4 -2
- package/docs/ARCHITECTURE.md +8 -1
- package/docs/DIGITAL_GARDEN.md +22 -1
- package/package.json +12 -12
- package/public/next-image-export-optimizer-hashes.json +3 -2
- package/scripts/new-flow.ts +1 -0
- package/site.config.example.ts +3 -4
- package/site.config.ts +6 -7
- package/src/app/[slug]/[postSlug]/page.tsx +19 -2
- package/src/app/[slug]/page/[page]/page.tsx +26 -5
- package/src/app/[slug]/page.tsx +28 -8
- package/src/app/all.atom/route.ts +7 -0
- package/src/app/all.xml/route.ts +7 -0
- package/src/app/archive/page.tsx +7 -4
- package/src/app/feed.atom/route.ts +2 -57
- package/src/app/feed.xml/route.ts +2 -64
- package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
- package/src/app/flows/feed.atom/route.ts +7 -0
- package/src/app/flows/feed.xml/route.ts +7 -0
- package/src/app/page.tsx +1 -2
- package/src/app/posts/[slug]/page.tsx +28 -9
- package/src/app/posts/feed.atom/route.ts +9 -0
- package/src/app/posts/feed.xml/route.ts +9 -0
- package/src/app/series/[slug]/page.tsx +46 -4
- package/src/components/CuratedSeriesSection.tsx +7 -11
- package/src/components/FeaturedStoriesSection.tsx +1 -1
- package/src/components/FlowCalendarSidebar.tsx +1 -1
- package/src/components/FlowContent.tsx +2 -1
- package/src/components/FlowTimelineEntry.tsx +7 -1
- package/src/components/Footer.tsx +6 -6
- package/src/components/HorizontalScroll.tsx +5 -14
- package/src/components/MarkdownRenderer.test.tsx +6 -0
- package/src/components/MarkdownRenderer.tsx +18 -16
- package/src/components/Navbar.tsx +1 -1
- package/src/components/PostList.tsx +20 -36
- package/src/components/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/components/SelectedBooksSection.tsx +65 -25
- package/src/components/SeriesCatalog.tsx +9 -7
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/PostLayout.tsx +1 -1
- package/src/layouts/SimpleLayout.tsx +3 -3
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/markdown.ts +26 -5
- package/src/lib/urls.ts +9 -4
- package/tests/e2e/mobile/mobile-compat.spec.ts +58 -0
- package/tests/e2e/navigation.test.ts +26 -0
- package/tests/integration/collections.test.ts +17 -2
- package/tests/integration/feed-utils.test.ts +52 -0
- package/tests/integration/flow-title.test.ts +53 -0
- package/tests/integration/markdown-features.test.ts +3 -3
- package/tests/integration/reading-time-headings.test.ts +2 -2
- package/tests/unit/static-params.test.ts +155 -22
- package/tests/unit/urls.test.ts +10 -12
- /package/content/posts/{multilingual-test.mdx → multilingual-test-/344/270/255/346/226/207/351/225/277/346/240/207/351/242/230.mdx"} +0 -0
|
@@ -164,22 +164,24 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
164
164
|
return (
|
|
165
165
|
<>
|
|
166
166
|
{latex && <KatexStyles />}
|
|
167
|
-
<div className="
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
167
|
+
<div className="bg-background"> {/* Explicit background for better copy-paste fidelity */}
|
|
168
|
+
<div className="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
|
|
169
|
+
prose-headings:font-serif prose-headings:text-heading
|
|
170
|
+
prose-p:text-foreground prose-p:leading-loose
|
|
171
|
+
prose-strong:text-heading prose-strong:font-semibold
|
|
172
|
+
prose-code:bg-muted/15 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:border prose-code:border-muted/20 prose-code:text-[0.9em] prose-code:font-medium
|
|
173
|
+
prose-code:before:content-none prose-code:after:content-none
|
|
174
|
+
prose-blockquote:italic
|
|
175
|
+
prose-th:text-heading prose-td:text-foreground
|
|
176
|
+
dark:prose-invert">
|
|
177
|
+
<ReactMarkdown
|
|
178
|
+
remarkPlugins={remarkPlugins}
|
|
179
|
+
rehypePlugins={rehypePlugins}
|
|
180
|
+
components={allComponents}
|
|
181
|
+
>
|
|
182
|
+
{content}
|
|
183
|
+
</ReactMarkdown>
|
|
184
|
+
</div>
|
|
183
185
|
</div>
|
|
184
186
|
</>
|
|
185
187
|
);
|
|
@@ -91,7 +91,7 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
91
91
|
}, [isMenuOpen]);
|
|
92
92
|
|
|
93
93
|
return (
|
|
94
|
-
<nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 ${
|
|
94
|
+
<nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
|
|
95
95
|
isScrolled
|
|
96
96
|
? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
|
|
97
97
|
: 'border-transparent bg-transparent'
|
|
@@ -37,64 +37,48 @@ export default function PostList({
|
|
|
37
37
|
/>
|
|
38
38
|
|
|
39
39
|
{/* Content card */}
|
|
40
|
-
<div className="rounded-2xl border border-muted/20 bg-muted/5 overflow-hidden transition-all duration-300 group-hover:border-accent/30 group-hover:bg-muted/10 group-hover:shadow-lg group-hover:shadow-accent/5">
|
|
41
|
-
<div className="flex flex-
|
|
40
|
+
<div className="rounded-2xl border border-muted/20 bg-muted/5 overflow-hidden transition-all duration-300 group-hover:border-accent/30 group-hover:bg-muted/10 group-hover:shadow-lg group-hover:shadow-accent/5 h-32 sm:h-auto">
|
|
41
|
+
<div className="flex flex-row h-full">
|
|
42
42
|
{/* Thumbnail */}
|
|
43
|
-
<div className="relative w-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<div className="absolute top-3 left-3 text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-0.5 rounded tracking-wider">
|
|
53
|
-
DRAFT
|
|
54
|
-
</div>
|
|
55
|
-
)}
|
|
43
|
+
<div className="relative w-32 sm:w-48 flex-shrink-0 overflow-hidden bg-muted/10">
|
|
44
|
+
<Link href={getPostUrl(post)} className="relative z-10 block h-full w-full" tabIndex={-1} aria-hidden>
|
|
45
|
+
<CoverImage
|
|
46
|
+
src={post.coverImage}
|
|
47
|
+
title={post.title}
|
|
48
|
+
slug={post.slug}
|
|
49
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
50
|
+
/>
|
|
51
|
+
</Link>
|
|
56
52
|
</div>
|
|
57
53
|
|
|
58
54
|
{/* Content */}
|
|
59
|
-
<div className="flex-1 p-
|
|
55
|
+
<div className="flex-1 p-4 sm:p-6 flex flex-col overflow-hidden">
|
|
60
56
|
{/* Meta info */}
|
|
61
|
-
<div className="flex items-center gap-x-2 text-xs font-mono text-muted mb-3 overflow-hidden">
|
|
57
|
+
<div className="flex items-center gap-x-2 text-xs font-mono text-muted mb-2 sm:mb-3 overflow-hidden">
|
|
62
58
|
{post.category && (
|
|
63
59
|
<>
|
|
64
|
-
<span className="text-accent uppercase tracking-wider truncate
|
|
65
|
-
<span className="shrink-0"
|
|
60
|
+
<span className="text-accent uppercase tracking-wider truncate max-w-[4rem]">{post.category}</span>
|
|
61
|
+
<span className="shrink-0">·</span>
|
|
66
62
|
</>
|
|
67
63
|
)}
|
|
68
|
-
<span className="shrink-0
|
|
69
|
-
<span className="shrink-0"
|
|
64
|
+
<span className="shrink-0 hidden sm:inline">{post.readingTime}</span>
|
|
65
|
+
<span className="shrink-0 hidden sm:inline">·</span>
|
|
70
66
|
<span className="shrink-0 whitespace-nowrap">{post.date}</span>
|
|
71
67
|
{post.draft && (
|
|
72
|
-
<span className="
|
|
68
|
+
<span className="text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded tracking-wider">
|
|
73
69
|
DRAFT
|
|
74
70
|
</span>
|
|
75
71
|
)}
|
|
76
72
|
</div>
|
|
77
73
|
|
|
78
74
|
{/* Title */}
|
|
79
|
-
<h3 className="font-serif text-xl font-bold text-heading mb-2 leading-snug group-hover:text-accent transition-colors line-clamp-2">
|
|
75
|
+
<h3 className="font-serif text-base sm:text-xl font-bold text-heading mb-1 sm:mb-2 leading-snug group-hover:text-accent transition-colors line-clamp-2">
|
|
80
76
|
{post.title}
|
|
81
77
|
</h3>
|
|
82
78
|
|
|
83
|
-
{/* Series indicator */}
|
|
84
|
-
{post.series && post.seriesTitle && (
|
|
85
|
-
<p className="text-xs text-muted mb-2">
|
|
86
|
-
<Link
|
|
87
|
-
href={`/series/${post.series}`}
|
|
88
|
-
className="relative z-10 hover:text-accent transition-colors no-underline"
|
|
89
|
-
>
|
|
90
|
-
{t('series')}: {post.seriesTitle}
|
|
91
|
-
</Link>
|
|
92
|
-
</p>
|
|
93
|
-
)}
|
|
94
|
-
|
|
95
79
|
{/* Excerpt */}
|
|
96
80
|
{showExcerpt && (post.subtitle || post.excerpt) && (
|
|
97
|
-
<p className={`text-sm text-muted leading-relaxed ${excerptLines === 1 ? 'line-clamp-1' : 'line-clamp-2 mb-4'}`}>
|
|
81
|
+
<p className={`text-xs sm:text-sm text-muted leading-relaxed ${excerptLines === 1 ? 'line-clamp-1' : 'line-clamp-2 sm:mb-4'}`}>
|
|
98
82
|
{post.subtitle || post.excerpt}
|
|
99
83
|
</p>
|
|
100
84
|
)}
|
|
@@ -73,7 +73,7 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
73
73
|
<aside
|
|
74
74
|
ref={sidebarRef}
|
|
75
75
|
data-testid="post-sidebar"
|
|
76
|
-
className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] overflow-y-auto pr-4 scrollbar-hide hover:scrollbar-thin"
|
|
76
|
+
className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] overflow-y-auto pr-4 scrollbar-hide hover:scrollbar-thin select-none"
|
|
77
77
|
>
|
|
78
78
|
{/* TOC — always at top */}
|
|
79
79
|
<TocPanel
|
|
@@ -6,6 +6,7 @@ import { useLanguage } from './LanguageProvider';
|
|
|
6
6
|
export interface RecentNoteItem {
|
|
7
7
|
slug: string;
|
|
8
8
|
date: string;
|
|
9
|
+
title?: string;
|
|
9
10
|
excerpt: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -36,6 +37,9 @@ export default function RecentNotesSection({ notes }: RecentNotesSectionProps) {
|
|
|
36
37
|
<div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
|
|
37
38
|
<Link href={`/flows/${note.slug}`} className="no-underline group">
|
|
38
39
|
<time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{note.date}</time>
|
|
40
|
+
{note.title && note.title !== note.date && (
|
|
41
|
+
<h3 className="mt-0.5 text-sm font-semibold text-heading group-hover:text-accent transition-colors">{note.title}</h3>
|
|
42
|
+
)}
|
|
39
43
|
</Link>
|
|
40
44
|
{note.excerpt && (
|
|
41
45
|
<p className="mt-1.5 text-sm text-muted line-clamp-2">{note.excerpt}</p>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
3
4
|
import Link from 'next/link';
|
|
4
5
|
import CoverImage from './CoverImage';
|
|
6
|
+
import HorizontalScroll from './HorizontalScroll';
|
|
5
7
|
import { useLanguage } from './LanguageProvider';
|
|
6
|
-
import {
|
|
8
|
+
import { shuffle, shuffleSeeded } from '@/lib/shuffle';
|
|
9
|
+
import { getBooksListUrl, getBookUrl, getBookChapterUrl } from '@/lib/urls';
|
|
7
10
|
|
|
8
11
|
export interface BookItem {
|
|
9
12
|
slug: string;
|
|
@@ -22,36 +25,70 @@ interface SelectedBooksSectionProps {
|
|
|
22
25
|
|
|
23
26
|
export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBooksSectionProps) {
|
|
24
27
|
const { t } = useLanguage();
|
|
25
|
-
const displayed =
|
|
28
|
+
const [displayed, setDisplayed] = useState(() => {
|
|
29
|
+
const dailySeed = Math.floor(Date.now() / 86400000);
|
|
30
|
+
return shuffleSeeded(books, dailySeed).slice(0, maxItems);
|
|
31
|
+
});
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
const handleShuffle = useCallback(() => {
|
|
34
|
+
setDisplayed(shuffle(books).slice(0, maxItems));
|
|
35
|
+
}, [books, maxItems]);
|
|
36
|
+
|
|
37
|
+
if (books.length === 0) return null;
|
|
28
38
|
|
|
29
39
|
return (
|
|
30
40
|
<section id="featured-books" className="mb-12 sm:mb-24">
|
|
31
41
|
<div className="flex items-center justify-between mb-8">
|
|
32
42
|
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('selected_books')}</h2>
|
|
33
|
-
<
|
|
34
|
-
{
|
|
35
|
-
|
|
43
|
+
<div className="flex items-center gap-4">
|
|
44
|
+
{books.length > maxItems && (
|
|
45
|
+
<button
|
|
46
|
+
onClick={handleShuffle}
|
|
47
|
+
className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
|
|
48
|
+
aria-label={t('shuffle_books')}
|
|
49
|
+
title={t('shuffle_books')}
|
|
50
|
+
>
|
|
51
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
|
52
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
|
|
53
|
+
</svg>
|
|
54
|
+
</button>
|
|
55
|
+
)}
|
|
56
|
+
<Link href={getBooksListUrl()} className="text-sm text-muted hover:text-accent transition-colors no-underline">
|
|
57
|
+
{t('all_books')} →
|
|
58
|
+
</Link>
|
|
59
|
+
</div>
|
|
36
60
|
</div>
|
|
37
|
-
<
|
|
38
|
-
{displayed.
|
|
39
|
-
|
|
40
|
-
<div
|
|
41
|
-
|
|
61
|
+
<HorizontalScroll>
|
|
62
|
+
<div className={`flex gap-8 ${displayed.length > 1 ? 'pb-4' : ''}`}>
|
|
63
|
+
{displayed.map((book, idx) => (
|
|
64
|
+
<div
|
|
65
|
+
key={book.slug}
|
|
66
|
+
className={`card-base group flex flex-col p-0 overflow-hidden snap-start ${
|
|
67
|
+
displayed.length > 1
|
|
68
|
+
? 'w-[85vw] md:w-[calc(50%-1rem)] flex-shrink-0'
|
|
69
|
+
: 'flex-1 md:max-w-[calc(50%-1rem)]'
|
|
70
|
+
}`}
|
|
71
|
+
>
|
|
72
|
+
<Link href={getBookUrl(book.slug)} className="relative h-44 w-full overflow-hidden bg-muted/10 block focus:outline-none focus:ring-2 focus:ring-accent/50 focus:ring-inset">
|
|
42
73
|
<CoverImage
|
|
43
74
|
src={book.coverImage}
|
|
44
75
|
title={book.title}
|
|
45
76
|
slug={book.slug}
|
|
46
|
-
className="h-full w-full object-cover transition-transform duration-
|
|
77
|
+
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
|
78
|
+
loading={idx === 0 ? 'eager' : undefined}
|
|
47
79
|
/>
|
|
48
|
-
|
|
80
|
+
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors duration-500" />
|
|
81
|
+
</Link>
|
|
49
82
|
<div className="p-6 flex flex-col flex-1">
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
83
|
+
<div className="mb-4">
|
|
84
|
+
<span className="badge-accent">
|
|
85
|
+
{book.chapterCount} {t('chapters_count')}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
<h3 className="mb-2 font-serif text-2xl font-bold text-heading group-hover:text-accent transition-colors line-clamp-2">
|
|
89
|
+
<Link href={getBookUrl(book.slug)} className="no-underline focus:outline-none focus:text-accent">
|
|
90
|
+
{book.title}
|
|
91
|
+
</Link>
|
|
55
92
|
</h3>
|
|
56
93
|
{book.authors.length > 0 && (
|
|
57
94
|
<p className="text-xs text-muted mb-3">
|
|
@@ -59,25 +96,28 @@ export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBo
|
|
|
59
96
|
</p>
|
|
60
97
|
)}
|
|
61
98
|
{book.excerpt && (
|
|
62
|
-
<p className="text-muted font-serif italic
|
|
99
|
+
<p className="mb-6 text-muted font-serif italic line-clamp-2 text-base">
|
|
63
100
|
{book.excerpt}
|
|
64
101
|
</p>
|
|
65
102
|
)}
|
|
66
103
|
{book.firstChapter && (
|
|
67
|
-
<div className="mt-auto pt-
|
|
68
|
-
<
|
|
104
|
+
<div className="mt-auto pt-6 border-t border-muted/10">
|
|
105
|
+
<Link
|
|
106
|
+
href={getBookChapterUrl(book.slug, book.firstChapter)}
|
|
107
|
+
className="text-sm font-sans font-bold text-accent flex items-center gap-1.5 no-underline hover:gap-2.5 transition-all"
|
|
108
|
+
>
|
|
69
109
|
{t('start_reading')}
|
|
70
110
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
71
111
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
72
112
|
</svg>
|
|
73
|
-
</
|
|
113
|
+
</Link>
|
|
74
114
|
</div>
|
|
75
115
|
)}
|
|
76
116
|
</div>
|
|
77
117
|
</div>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
</
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
</HorizontalScroll>
|
|
81
121
|
</section>
|
|
82
122
|
);
|
|
83
123
|
}
|
|
@@ -46,14 +46,16 @@ export default function SeriesCatalog({ posts, startIndex = 0, totalPosts, colle
|
|
|
46
46
|
<div className="flex flex-col sm:flex-row">
|
|
47
47
|
{/* Thumbnail */}
|
|
48
48
|
<div className="relative w-full sm:w-48 h-40 sm:h-auto flex-shrink-0 overflow-hidden bg-muted/10">
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
<Link href={postHref(post)} className="relative z-10 block h-full w-full" tabIndex={-1} aria-hidden>
|
|
50
|
+
<CoverImage
|
|
51
|
+
src={post.coverImage}
|
|
52
|
+
title={post.title}
|
|
53
|
+
slug={post.slug}
|
|
54
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
55
|
+
/>
|
|
56
|
+
</Link>
|
|
55
57
|
{/* Mobile number badge */}
|
|
56
|
-
<div className="absolute top-3 left-3 md:hidden flex h-8 w-8 items-center justify-center rounded-full bg-background/90 backdrop-blur border border-muted/20">
|
|
58
|
+
<div className="absolute top-3 left-3 z-10 md:hidden flex h-8 w-8 items-center justify-center rounded-full bg-background/90 backdrop-blur border border-muted/20">
|
|
57
59
|
<span className="text-xs font-mono font-bold text-muted">
|
|
58
60
|
{padNumber(startIndex + index + 1)}
|
|
59
61
|
</span>
|
package/src/i18n/translations.ts
CHANGED
|
@@ -42,6 +42,7 @@ export const translations = {
|
|
|
42
42
|
series_default_excerpt: "A growing collection of related thoughts.",
|
|
43
43
|
shuffle_series: "Shuffle series",
|
|
44
44
|
shuffle_posts: "Shuffle featured stories",
|
|
45
|
+
shuffle_books: "Shuffle books",
|
|
45
46
|
books_subtitle: "{count} long-form books and structured guides.",
|
|
46
47
|
books_subtitle_one: "1 long-form book and structured guide.",
|
|
47
48
|
tags_subtitle: "{count} topics spanning all articles and notes.",
|
|
@@ -187,6 +188,7 @@ export const translations = {
|
|
|
187
188
|
series_default_excerpt: "一个持续更新的文章合集。",
|
|
188
189
|
shuffle_series: "随机排列系列",
|
|
189
190
|
shuffle_posts: "随机排列精选文章",
|
|
191
|
+
shuffle_books: "随机排列书籍",
|
|
190
192
|
books_subtitle: "共 {count} 部长篇书籍与结构化指南。",
|
|
191
193
|
books_subtitle_one: "1 部长篇书籍与结构化指南。",
|
|
192
194
|
tags_subtitle: "共 {count} 个主题,横跨全部文章与随笔。",
|
|
@@ -136,7 +136,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
136
136
|
</div>
|
|
137
137
|
)}
|
|
138
138
|
|
|
139
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={
|
|
139
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />
|
|
140
140
|
|
|
141
141
|
{siteConfig.posts?.authors?.showAuthorCard !== false && (
|
|
142
142
|
<AuthorCard authors={post.authors} />
|
|
@@ -40,16 +40,16 @@ export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayo
|
|
|
40
40
|
{localeEntries.length > 0 ? (
|
|
41
41
|
<LocaleSwitch>
|
|
42
42
|
<div data-locale={defaultLocale}>
|
|
43
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={
|
|
43
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} />
|
|
44
44
|
</div>
|
|
45
45
|
{localeEntries.map(([locale, data]) => (
|
|
46
46
|
<div key={locale} data-locale={locale} style={{ display: 'none' }}>
|
|
47
|
-
<MarkdownRenderer content={data.content} latex={post.latex} slug={
|
|
47
|
+
<MarkdownRenderer content={data.content} latex={post.latex} slug={post.imageBaseSlug} />
|
|
48
48
|
</div>
|
|
49
49
|
))}
|
|
50
50
|
</LocaleSwitch>
|
|
51
51
|
) : (
|
|
52
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={
|
|
52
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} />
|
|
53
53
|
)}
|
|
54
54
|
</>
|
|
55
55
|
);
|
package/src/lib/feed-utils.ts
CHANGED
|
@@ -7,6 +7,7 @@ import rehypeStringify from 'rehype-stringify';
|
|
|
7
7
|
import { getAllPosts, getAllFlows } from './markdown';
|
|
8
8
|
import { siteConfig } from '../../site.config';
|
|
9
9
|
import { getPostUrl, getFlowUrl } from './urls';
|
|
10
|
+
import { resolveLocale } from './i18n';
|
|
10
11
|
|
|
11
12
|
export interface FeedItem {
|
|
12
13
|
title: string;
|
|
@@ -30,39 +31,178 @@ function markdownToHtml(markdown: string): string {
|
|
|
30
31
|
return String(result);
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
export type FeedType = 'main' | 'posts' | 'flows' | 'all';
|
|
35
|
+
|
|
33
36
|
/**
|
|
34
37
|
* Returns feed items for RSS/Atom generation.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
+
* - 'main': Respects `siteConfig.feed.includeFlows`
|
|
39
|
+
* - 'posts': Only posts
|
|
40
|
+
* - 'flows': Only flows
|
|
41
|
+
* - 'all': Both posts and flows, ignoring `includeFlows`
|
|
38
42
|
*/
|
|
39
|
-
export function getFeedItems(): FeedItem[] {
|
|
43
|
+
export function getFeedItems(feedType: FeedType = 'main', includeFullContent: boolean = false): FeedItem[] {
|
|
40
44
|
const { maxItems, includeFlows } = siteConfig.feed;
|
|
41
45
|
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
let items: FeedItem[] = [];
|
|
48
|
+
|
|
49
|
+
const getPostItems = () => getAllPosts().map((post) => ({
|
|
44
50
|
title: post.title,
|
|
45
51
|
url: `${baseUrl}${getPostUrl(post)}`,
|
|
46
52
|
date: new Date(post.date),
|
|
47
53
|
excerpt: post.excerpt,
|
|
48
|
-
content: markdownToHtml(post.content),
|
|
54
|
+
content: includeFullContent ? markdownToHtml(post.content) : '',
|
|
49
55
|
tags: post.tags || [],
|
|
50
56
|
authors: post.authors,
|
|
51
57
|
}));
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
const getFlowItems = () => getAllFlows().map((flow) => ({
|
|
60
|
+
title: flow.title,
|
|
61
|
+
url: `${baseUrl}${getFlowUrl(flow.slug)}`,
|
|
62
|
+
date: new Date(flow.date),
|
|
63
|
+
excerpt: flow.excerpt,
|
|
64
|
+
content: includeFullContent ? markdownToHtml(flow.content) : '',
|
|
65
|
+
tags: flow.tags || [],
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
if (feedType === 'posts') {
|
|
69
|
+
items = getPostItems();
|
|
70
|
+
} else if (feedType === 'flows') {
|
|
71
|
+
items = getFlowItems();
|
|
72
|
+
} else if (feedType === 'all') {
|
|
73
|
+
items = [...getPostItems(), ...getFlowItems()];
|
|
74
|
+
} else {
|
|
75
|
+
// main
|
|
76
|
+
items = includeFlows ? [...getPostItems(), ...getFlowItems()] : getPostItems();
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
// Sort descending by date
|
|
80
|
+
items.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
81
|
+
|
|
67
82
|
return maxItems > 0 ? items.slice(0, maxItems) : items;
|
|
68
83
|
}
|
|
84
|
+
|
|
85
|
+
const escapeXml = (v: string) =>
|
|
86
|
+
v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
87
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
88
|
+
|
|
89
|
+
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
90
|
+
|
|
91
|
+
export function generateRssFeed(feedType: FeedType, selfUrlPath: string): Response {
|
|
92
|
+
const { format, content: contentMode } = siteConfig.feed;
|
|
93
|
+
if (format === 'atom') {
|
|
94
|
+
return new Response('Not Found', { status: 404 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
98
|
+
const useFullContent = contentMode === 'full';
|
|
99
|
+
const items = getFeedItems(feedType, useFullContent);
|
|
100
|
+
const contentNs = useFullContent ? ' xmlns:content="http://purl.org/rss/modules/content/"' : '';
|
|
101
|
+
const siteTitle = resolveLocale(siteConfig.title);
|
|
102
|
+
const lastBuildDate = items[0]?.date.toUTCString() ?? new Date().toUTCString();
|
|
103
|
+
|
|
104
|
+
const selfUrl = `${baseUrl}${selfUrlPath}`;
|
|
105
|
+
|
|
106
|
+
const imageXml = siteConfig.ogImage
|
|
107
|
+
? `\n <image>\n <url>${escapeXml(baseUrl + siteConfig.ogImage)}</url>\n <title>${escapeXml(siteTitle)}</title>\n <link>${escapeXml(baseUrl)}</link>\n </image>`
|
|
108
|
+
: '';
|
|
109
|
+
|
|
110
|
+
const rssItemsXml = items
|
|
111
|
+
.map((item) => {
|
|
112
|
+
const fullContentXml = useFullContent
|
|
113
|
+
? `\n <content:encoded><![CDATA[${escapeCdata(item.content)}]]></content:encoded>`
|
|
114
|
+
: '';
|
|
115
|
+
const authorsXml = item.authors?.length
|
|
116
|
+
? item.authors.map((a) => `\n <dc:creator><![CDATA[${escapeCdata(a)}]]></dc:creator>`).join('')
|
|
117
|
+
: '';
|
|
118
|
+
return `
|
|
119
|
+
<item>
|
|
120
|
+
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
121
|
+
<link>${escapeXml(item.url)}</link>
|
|
122
|
+
<guid isPermaLink="true">${escapeXml(item.url)}</guid>
|
|
123
|
+
<pubDate>${item.date.toUTCString()}</pubDate>
|
|
124
|
+
<description><![CDATA[${escapeCdata(item.excerpt)}]]></description>${fullContentXml}${authorsXml}
|
|
125
|
+
${item.tags.map((tag) => `<category><![CDATA[${escapeCdata(tag)}]]></category>`).join('')}
|
|
126
|
+
</item>`;
|
|
127
|
+
})
|
|
128
|
+
.join('');
|
|
129
|
+
|
|
130
|
+
const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
131
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"${contentNs}>
|
|
132
|
+
<channel>
|
|
133
|
+
<title><![CDATA[${escapeCdata(siteTitle)}]]></title>
|
|
134
|
+
<link>${escapeXml(baseUrl)}</link>
|
|
135
|
+
<description><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></description>
|
|
136
|
+
<language>${siteConfig.i18n.defaultLocale}</language>
|
|
137
|
+
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
138
|
+
<atom:link href="${escapeXml(selfUrl)}" rel="self" type="application/rss+xml" />${imageXml}
|
|
139
|
+
${rssItemsXml}
|
|
140
|
+
</channel>
|
|
141
|
+
</rss>`;
|
|
142
|
+
|
|
143
|
+
return new Response(rssXml, {
|
|
144
|
+
headers: {
|
|
145
|
+
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
146
|
+
'Cache-Control': 'public, max-age=3600',
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function generateAtomFeed(feedType: FeedType, selfUrlPath: string): Response {
|
|
152
|
+
const { format, content: contentMode } = siteConfig.feed;
|
|
153
|
+
if (format === 'rss') {
|
|
154
|
+
return new Response('Not Found', { status: 404 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
158
|
+
const useFullContent = contentMode === 'full';
|
|
159
|
+
const items = getFeedItems(feedType, useFullContent);
|
|
160
|
+
const feedUpdated = items[0]?.date.toISOString() ?? new Date().toISOString();
|
|
161
|
+
|
|
162
|
+
const selfUrl = `${baseUrl}${selfUrlPath}`;
|
|
163
|
+
|
|
164
|
+
const hasAllAuthors = items.every(item => item.authors && item.authors.length > 0);
|
|
165
|
+
const siteTitle = resolveLocale(siteConfig.title);
|
|
166
|
+
const defaultAuthor = siteConfig.posts?.authors?.default?.[0];
|
|
167
|
+
const feedAuthorName = defaultAuthor ? defaultAuthor : siteTitle;
|
|
168
|
+
const feedAuthorXml = hasAllAuthors ? '' : `\n <author><name>${escapeXml(feedAuthorName)}</name></author>`;
|
|
169
|
+
|
|
170
|
+
const entriesXml = items
|
|
171
|
+
.map((item) => {
|
|
172
|
+
const contentXml = useFullContent
|
|
173
|
+
? `<content type="html"><![CDATA[${escapeCdata(item.content)}]]></content>\n <summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`
|
|
174
|
+
: `<summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`;
|
|
175
|
+
const authorsXml = item.authors?.map((a) => `<author><name>${escapeXml(a)}</name></author>`).join('') ?? '';
|
|
176
|
+
const categoriesXml = item.tags.map((tag) => `<category term="${escapeXml(tag)}" />`).join('');
|
|
177
|
+
return `
|
|
178
|
+
<entry>
|
|
179
|
+
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
180
|
+
<link href="${escapeXml(item.url)}" />
|
|
181
|
+
<id>${escapeXml(item.url)}</id>
|
|
182
|
+
<published>${item.date.toISOString()}</published>
|
|
183
|
+
<updated>${item.date.toISOString()}</updated>
|
|
184
|
+
${contentXml}
|
|
185
|
+
${authorsXml}
|
|
186
|
+
${categoriesXml}
|
|
187
|
+
</entry>`;
|
|
188
|
+
})
|
|
189
|
+
.join('');
|
|
190
|
+
|
|
191
|
+
const atomXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
192
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
193
|
+
<title><![CDATA[${escapeCdata(resolveLocale(siteConfig.title))}]]></title>
|
|
194
|
+
<link href="${escapeXml(baseUrl)}" />
|
|
195
|
+
<link href="${escapeXml(selfUrl)}" rel="self" type="application/atom+xml" />
|
|
196
|
+
<id>${escapeXml(selfUrl)}</id>
|
|
197
|
+
<updated>${feedUpdated}</updated>
|
|
198
|
+
<subtitle><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></subtitle>${feedAuthorXml}
|
|
199
|
+
${entriesXml}
|
|
200
|
+
</feed>`;
|
|
201
|
+
|
|
202
|
+
return new Response(atomXml, {
|
|
203
|
+
headers: {
|
|
204
|
+
'Content-Type': 'application/atom+xml; charset=utf-8',
|
|
205
|
+
'Cache-Control': 'public, max-age=3600',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|