@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/GEMINI.md +9 -1
  3. package/README.md +26 -17
  4. package/README.zh.md +180 -100
  5. package/bun.lock +78 -74
  6. package/content/books/notes-on-thinking/cost-of-certainty.mdx +9 -0
  7. package/content/books/notes-on-thinking/index.mdx +16 -0
  8. package/content/books/notes-on-thinking/mental-models.mdx +9 -0
  9. package/content/books/the-pragmatic-writer/finding-your-voice.mdx +9 -0
  10. package/content/books/the-pragmatic-writer/index.mdx +18 -0
  11. package/content/books/the-pragmatic-writer/the-editing-loop.mdx +9 -0
  12. package/content/books/the-pragmatic-writer/why-writing-matters.mdx +9 -0
  13. package/content/flows/2026/03/01.md +9 -0
  14. package/content/flows/2026/03/03.md +9 -0
  15. package/content/flows/2026/03/05.md +10 -0
  16. package/content/flows/2026/03/07.md +11 -0
  17. package/content/posts/images/vibrant-waves.jpg +0 -0
  18. package/content/posts/welcome-to-amytis.mdx +3 -0
  19. package/content/series/markdown-showcase/index.mdx +2 -1
  20. package/content/series/markdown-showcase/mathematical-notation.mdx +8 -4
  21. package/content/series/markdown-showcase/syntax-highlighting.mdx +9 -5
  22. package/content/series/markdown-showcase/visuals-and-diagrams.mdx +8 -4
  23. 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
  24. package/content/series/modern-web-dev/index.mdx +4 -2
  25. package/docs/ARCHITECTURE.md +8 -1
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/package.json +12 -12
  28. package/public/next-image-export-optimizer-hashes.json +3 -2
  29. package/scripts/new-flow.ts +1 -0
  30. package/site.config.example.ts +3 -4
  31. package/site.config.ts +6 -7
  32. package/src/app/[slug]/[postSlug]/page.tsx +19 -2
  33. package/src/app/[slug]/page/[page]/page.tsx +26 -5
  34. package/src/app/[slug]/page.tsx +28 -8
  35. package/src/app/all.atom/route.ts +7 -0
  36. package/src/app/all.xml/route.ts +7 -0
  37. package/src/app/archive/page.tsx +7 -4
  38. package/src/app/feed.atom/route.ts +2 -57
  39. package/src/app/feed.xml/route.ts +2 -64
  40. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  41. package/src/app/flows/feed.atom/route.ts +7 -0
  42. package/src/app/flows/feed.xml/route.ts +7 -0
  43. package/src/app/page.tsx +1 -2
  44. package/src/app/posts/[slug]/page.tsx +28 -9
  45. package/src/app/posts/feed.atom/route.ts +9 -0
  46. package/src/app/posts/feed.xml/route.ts +9 -0
  47. package/src/app/series/[slug]/page.tsx +46 -4
  48. package/src/components/CuratedSeriesSection.tsx +7 -11
  49. package/src/components/FeaturedStoriesSection.tsx +1 -1
  50. package/src/components/FlowCalendarSidebar.tsx +1 -1
  51. package/src/components/FlowContent.tsx +2 -1
  52. package/src/components/FlowTimelineEntry.tsx +7 -1
  53. package/src/components/Footer.tsx +6 -6
  54. package/src/components/HorizontalScroll.tsx +5 -14
  55. package/src/components/MarkdownRenderer.test.tsx +6 -0
  56. package/src/components/MarkdownRenderer.tsx +18 -16
  57. package/src/components/Navbar.tsx +1 -1
  58. package/src/components/PostList.tsx +20 -36
  59. package/src/components/PostSidebar.tsx +1 -1
  60. package/src/components/RecentNotesSection.tsx +4 -0
  61. package/src/components/SelectedBooksSection.tsx +65 -25
  62. package/src/components/SeriesCatalog.tsx +9 -7
  63. package/src/i18n/translations.ts +2 -0
  64. package/src/layouts/PostLayout.tsx +1 -1
  65. package/src/layouts/SimpleLayout.tsx +3 -3
  66. package/src/lib/feed-utils.ts +158 -18
  67. package/src/lib/markdown.ts +26 -5
  68. package/src/lib/urls.ts +9 -4
  69. package/tests/e2e/mobile/mobile-compat.spec.ts +58 -0
  70. package/tests/e2e/navigation.test.ts +26 -0
  71. package/tests/integration/collections.test.ts +17 -2
  72. package/tests/integration/feed-utils.test.ts +52 -0
  73. package/tests/integration/flow-title.test.ts +53 -0
  74. package/tests/integration/markdown-features.test.ts +3 -3
  75. package/tests/integration/reading-time-headings.test.ts +2 -2
  76. package/tests/unit/static-params.test.ts +155 -22
  77. package/tests/unit/urls.test.ts +10 -12
  78. /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="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
168
- prose-headings:font-serif prose-headings:text-heading
169
- prose-p:text-foreground prose-p:leading-loose
170
- prose-strong:text-heading prose-strong:font-semibold
171
- 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
172
- prose-code:before:content-none prose-code:after:content-none
173
- prose-blockquote:italic
174
- prose-th:text-heading prose-td:text-foreground
175
- dark:prose-invert">
176
- <ReactMarkdown
177
- remarkPlugins={remarkPlugins}
178
- rehypePlugins={rehypePlugins}
179
- components={allComponents}
180
- >
181
- {content}
182
- </ReactMarkdown>
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-col sm:flex-row">
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-full sm:w-48 h-40 sm:h-auto flex-shrink-0 overflow-hidden bg-muted/10">
44
- <CoverImage
45
- src={post.coverImage}
46
- title={post.title}
47
- slug={post.slug}
48
- className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
49
- />
50
- {/* Draft badge on mobile */}
51
- {post.draft && (
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-5 sm:p-6 flex flex-col overflow-hidden">
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 min-w-0">{post.category}</span>
65
- <span className="shrink-0">•</span>
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 whitespace-nowrap">{post.readingTime}</span>
69
- <span className="shrink-0">•</span>
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="hidden sm:inline text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-1.5 py-0.5 rounded tracking-wider">
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 { getBooksListUrl, getBookUrl } from '@/lib/urls';
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 = books.slice(0, maxItems);
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
- if (displayed.length === 0) return null;
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
- <Link href={getBooksListUrl()} className="text-sm text-muted hover:text-accent transition-colors no-underline">
34
- {t('all_books')} →
35
- </Link>
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
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
38
- {displayed.map(book => (
39
- <Link key={book.slug} href={getBookUrl(book.slug)} className="group block no-underline">
40
- <div className="card-base h-full group flex flex-col p-0 overflow-hidden">
41
- <div className="relative h-48 w-full overflow-hidden bg-muted/10">
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-500 group-hover:scale-105"
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
- </div>
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
- <span className="badge-accent self-start">
51
- {book.chapterCount} {t('chapters_count')}
52
- </span>
53
- <h3 className="mb-3 font-serif text-xl font-bold text-heading group-hover:text-accent transition-colors line-clamp-2">
54
- {book.title}
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 leading-relaxed line-clamp-3 text-sm mb-4">
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-4 border-t border-muted/10">
68
- <span className="text-sm font-sans font-bold text-accent flex items-center gap-1.5">
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
- </span>
113
+ </Link>
74
114
  </div>
75
115
  )}
76
116
  </div>
77
117
  </div>
78
- </Link>
79
- ))}
80
- </div>
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
- <CoverImage
50
- src={post.coverImage}
51
- title={post.title}
52
- slug={post.slug}
53
- className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
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>
@@ -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={`posts/${post.slug}`} slugRegistry={slugRegistry} />
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={`posts/${post.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={`posts/${post.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={`posts/${post.slug}`} />
52
+ <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} />
53
53
  )}
54
54
  </>
55
55
  );
@@ -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
- * Includes all published posts (converted to HTML) and optionally flow notes
36
- * when `siteConfig.feed.includeFlows` is enabled. Results are sorted by date
37
- * descending and capped at `siteConfig.feed.maxItems` (0 = unlimited).
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
- const postItems: FeedItem[] = getAllPosts().map((post) => ({
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
- let items: FeedItem[] = postItems;
54
-
55
- if (includeFlows) {
56
- const flowItems: FeedItem[] = getAllFlows().map((flow) => ({
57
- title: flow.title,
58
- url: `${baseUrl}${getFlowUrl(flow.slug)}`,
59
- date: new Date(flow.date),
60
- excerpt: flow.excerpt,
61
- content: markdownToHtml(flow.content),
62
- tags: flow.tags || [],
63
- }));
64
- items = [...postItems, ...flowItems].sort((a, b) => b.date.getTime() - a.date.getTime());
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
87
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
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
+ }