@hutusi/amytis 1.9.0 → 1.11.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 (143) hide show
  1. package/.entire/settings.json +4 -0
  2. package/AGENTS.md +13 -7
  3. package/CHANGELOG.md +41 -0
  4. package/CLAUDE.md +30 -2
  5. package/README.md +84 -58
  6. package/README.zh.md +195 -0
  7. package/TODO.md +6 -4
  8. package/bun.lock +30 -4
  9. package/content/books/sample-book/images/digital-garden.svg +25 -0
  10. package/content/books/sample-book/index.mdx +1 -1
  11. package/content/books/sample-book/introduction.mdx +2 -0
  12. package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
  13. package/content/posts/legacy-markdown.md +0 -2
  14. package/content/posts/markdown-features.mdx +1 -1
  15. package/content/posts/multilingual-test.mdx +0 -1
  16. package/content/posts/syntax-highlighting-showcase.mdx +1 -0
  17. package/content/posts/understanding-react-hooks.mdx +1 -0
  18. package/content/series/ai-nexus-weekly/index.mdx +1 -1
  19. package/content/series/digital-garden/01-philosophy.mdx +2 -0
  20. package/content/series/digital-garden/index.mdx +1 -1
  21. package/content/series/markdown-showcase/index.mdx +1 -1
  22. package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
  23. package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
  24. package/content/series/nextjs-deep-dive/index.mdx +1 -1
  25. package/content/subscribe.mdx +27 -0
  26. package/docs/ARCHITECTURE.md +85 -105
  27. package/docs/CONTRIBUTING.md +11 -3
  28. package/docs/DIGITAL_GARDEN.md +11 -9
  29. package/docs/deployment.md +31 -13
  30. package/eslint.config.mjs +2 -0
  31. package/package.json +13 -4
  32. package/packages/create-amytis/package.json +13 -0
  33. package/packages/create-amytis/src/index.test.ts +187 -0
  34. package/packages/create-amytis/src/index.ts +230 -0
  35. package/packages/create-amytis/tsconfig.json +14 -0
  36. package/public/images/amytis-screenshot.jpg +0 -0
  37. package/public/images/antelope-canyon.jpg +0 -0
  38. package/public/images/avatar.jpg +0 -0
  39. package/public/images/cappadocia.jpg +0 -0
  40. package/public/images/flowers.jpg +0 -0
  41. package/public/images/galaxy.jpg +0 -0
  42. package/public/images/lake.jpg +0 -0
  43. package/public/images/mountains.jpg +0 -0
  44. package/public/images/vibrant-waves.avif +0 -0
  45. package/public/images/wechat-qr.jpg +0 -0
  46. package/public/next-image-export-optimizer-hashes.json +72 -2
  47. package/scripts/deploy.ts +77 -0
  48. package/site.config.example.ts +268 -0
  49. package/site.config.ts +82 -10
  50. package/src/app/[slug]/[postSlug]/page.tsx +161 -0
  51. package/src/app/[slug]/page/[page]/page.tsx +202 -0
  52. package/src/app/[slug]/page.tsx +162 -7
  53. package/src/app/archive/page.tsx +5 -8
  54. package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
  55. package/src/app/books/[slug]/page.tsx +16 -2
  56. package/src/app/books/page.tsx +12 -8
  57. package/src/app/feed.atom/route.ts +62 -0
  58. package/src/app/feed.xml/route.ts +45 -20
  59. package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
  60. package/src/app/flows/[year]/page.tsx +1 -1
  61. package/src/app/flows/page/[page]/page.tsx +2 -0
  62. package/src/app/flows/page.tsx +4 -0
  63. package/src/app/globals.css +9 -3
  64. package/src/app/layout.tsx +22 -1
  65. package/src/app/notes/[slug]/page.tsx +2 -3
  66. package/src/app/page.tsx +86 -8
  67. package/src/app/posts/[slug]/page.tsx +33 -7
  68. package/src/app/posts/page/[page]/page.tsx +9 -5
  69. package/src/app/posts/page.tsx +13 -7
  70. package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
  71. package/src/app/series/[slug]/page.tsx +21 -19
  72. package/src/app/series/page.tsx +14 -28
  73. package/src/app/sitemap.ts +2 -1
  74. package/src/app/tags/[tag]/page.tsx +1 -1
  75. package/src/app/tags/page.tsx +1 -1
  76. package/src/components/Analytics.tsx +34 -36
  77. package/src/components/AuthorCard.tsx +56 -14
  78. package/src/components/BookMobileNav.tsx +8 -34
  79. package/src/components/BookSidebar.tsx +17 -107
  80. package/src/components/CodeBlock.test.tsx +19 -0
  81. package/src/components/CodeBlock.tsx +20 -16
  82. package/src/components/Comments.tsx +4 -3
  83. package/src/components/CoverImage.tsx +11 -3
  84. package/src/components/CuratedSeriesSection.tsx +17 -15
  85. package/src/components/ExternalLinks.tsx +1 -1
  86. package/src/components/FeaturedStoriesSection.tsx +124 -58
  87. package/src/components/FlowCalendarSidebar.tsx +10 -7
  88. package/src/components/FlowContent.tsx +33 -5
  89. package/src/components/Footer.tsx +28 -6
  90. package/src/components/Hero.tsx +44 -92
  91. package/src/components/InlineBookToc.tsx +56 -0
  92. package/src/components/KnowledgeGraph.tsx +26 -0
  93. package/src/components/LanguageProvider.tsx +7 -2
  94. package/src/components/LanguageSwitch.tsx +1 -1
  95. package/src/components/LatestWritingSection.tsx +12 -22
  96. package/src/components/MarkdownRenderer.test.tsx +41 -0
  97. package/src/components/MarkdownRenderer.tsx +43 -8
  98. package/src/components/Navbar.tsx +24 -15
  99. package/src/components/NoteSidebar.tsx +6 -76
  100. package/src/components/Pagination.tsx +2 -2
  101. package/src/components/PostCard.tsx +2 -1
  102. package/src/components/PostList.tsx +81 -62
  103. package/src/components/PostNavigation.tsx +8 -7
  104. package/src/components/PostSidebar.tsx +12 -107
  105. package/src/components/PrevNextNav.tsx +63 -0
  106. package/src/components/RecentNotesSection.tsx +2 -2
  107. package/src/components/RelatedPosts.tsx +3 -2
  108. package/src/components/RssFeedWidget.tsx +55 -0
  109. package/src/components/SectionLabel.tsx +16 -0
  110. package/src/components/SelectedBooksSection.tsx +12 -15
  111. package/src/components/SeriesCatalog.tsx +74 -69
  112. package/src/components/SeriesList.tsx +11 -36
  113. package/src/components/SeriesSidebar.tsx +4 -2
  114. package/src/components/TagContentTabs.tsx +17 -12
  115. package/src/components/TagPageHeader.tsx +12 -2
  116. package/src/components/TagSidebar.tsx +3 -2
  117. package/src/components/TagsIndexClient.tsx +1 -1
  118. package/src/components/TocPanel.tsx +67 -0
  119. package/src/hooks/useActiveHeading.ts +32 -0
  120. package/src/hooks/useSidebarAutoScroll.ts +19 -0
  121. package/src/i18n/translations.ts +26 -4
  122. package/src/layouts/BookLayout.tsx +10 -40
  123. package/src/layouts/PostLayout.tsx +37 -26
  124. package/src/layouts/SimpleLayout.tsx +5 -5
  125. package/src/lib/feed-utils.ts +68 -0
  126. package/src/lib/format-utils.test.ts +27 -0
  127. package/src/lib/format-utils.ts +3 -0
  128. package/src/lib/image-utils.ts +12 -0
  129. package/src/lib/json-ld.ts +236 -0
  130. package/src/lib/markdown.ts +62 -7
  131. package/src/lib/rehype-image-metadata.test.ts +124 -0
  132. package/src/lib/rehype-image-metadata.ts +21 -5
  133. package/src/lib/scroll-utils.ts +11 -0
  134. package/src/lib/shuffle.ts +21 -0
  135. package/src/lib/urls.ts +51 -0
  136. package/tests/e2e/navigation.test.ts +15 -1
  137. package/tests/integration/books.test.ts +61 -0
  138. package/tests/integration/feed-utils.test.ts +145 -0
  139. package/tests/integration/series.test.ts +9 -0
  140. package/tests/unit/static-params.test.ts +59 -2
  141. package/src/app/subscribe/page.tsx +0 -17
  142. package/src/components/SubscribePage.tsx +0 -298
  143. /package/public/{screenshot.png → images/screenshot.png} +0 -0
@@ -4,6 +4,9 @@ import { useState } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { PostData } from '@/lib/markdown';
6
6
  import { useLanguage } from './LanguageProvider';
7
+ import { getPostUrl } from '@/lib/urls';
8
+ import PrevNextNav from './PrevNextNav';
9
+ import { padNumber } from '@/lib/format-utils';
7
10
 
8
11
  interface SeriesListProps {
9
12
  seriesSlug: string;
@@ -52,39 +55,11 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
52
55
  </div>
53
56
 
54
57
  {/* Prev / Next navigation */}
55
- <div className="flex gap-3 mb-3">
56
- {prevPost ? (
57
- <Link
58
- href={`/posts/${prevPost.slug}`}
59
- className="flex-1 flex items-center gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group"
60
- >
61
- <svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
62
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
63
- </svg>
64
- <div className="min-w-0">
65
- <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('prev')}</span>
66
- <span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{prevPost.title}</span>
67
- </div>
68
- </Link>
69
- ) : (
70
- <div className="flex-1" />
71
- )}
72
- {nextPost ? (
73
- <Link
74
- href={`/posts/${nextPost.slug}`}
75
- className="flex-1 flex items-center justify-end gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group text-right"
76
- >
77
- <div className="min-w-0">
78
- <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('next')}</span>
79
- <span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{nextPost.title}</span>
80
- </div>
81
- <svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
82
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
83
- </svg>
84
- </Link>
85
- ) : (
86
- <div className="flex-1" />
87
- )}
58
+ <div className="mb-3">
59
+ <PrevNextNav
60
+ prev={prevPost ? { href: getPostUrl(prevPost), title: prevPost.title } : null}
61
+ next={nextPost ? { href: getPostUrl(nextPost), title: nextPost.title } : null}
62
+ />
88
63
  </div>
89
64
 
90
65
  {/* Toggle to expand full list */}
@@ -117,7 +92,7 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
117
92
  {isCurrent ? (
118
93
  <div className="flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-lg bg-accent/5">
119
94
  <span className="flex-shrink-0 w-5 h-5 rounded-full bg-accent text-white text-[10px] font-mono font-bold flex items-center justify-center">
120
- {String(index + 1).padStart(2, '0')}
95
+ {padNumber(index + 1)}
121
96
  </span>
122
97
  <span className="text-sm font-semibold text-accent truncate">
123
98
  {post.title}
@@ -125,7 +100,7 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
125
100
  </div>
126
101
  ) : (
127
102
  <Link
128
- href={`/posts/${post.slug}`}
103
+ href={getPostUrl(post)}
129
104
  className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-lg hover:bg-muted/5 no-underline transition-colors"
130
105
  >
131
106
  <span className={`flex-shrink-0 w-5 h-5 rounded-full text-[10px] font-mono font-bold flex items-center justify-center transition-colors ${
@@ -133,7 +108,7 @@ export default function SeriesList({ seriesSlug, seriesTitle, posts, currentSlug
133
108
  ? 'bg-accent/20 text-accent'
134
109
  : 'bg-muted/10 text-muted group-hover:bg-muted/20'
135
110
  }`}>
136
- {String(index + 1).padStart(2, '0')}
111
+ {padNumber(index + 1)}
137
112
  </span>
138
113
  <span className={`text-sm truncate transition-colors ${
139
114
  isPast
@@ -4,6 +4,8 @@ import { useEffect, useRef } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { PostData } from '@/lib/markdown';
6
6
  import { useLanguage } from './LanguageProvider';
7
+ import { getPostUrl } from '@/lib/urls';
8
+ import { padNumber } from '@/lib/format-utils';
7
9
 
8
10
  interface SeriesSidebarProps {
9
11
  seriesSlug: string;
@@ -77,7 +79,7 @@ export default function SeriesSidebar({ seriesSlug, seriesTitle, posts, currentS
77
79
  return (
78
80
  <li key={post.slug} ref={isCurrent ? currentItemRef : undefined} className="relative">
79
81
  <Link
80
- href={`/posts/${post.slug}`}
82
+ href={getPostUrl(post)}
81
83
  className={`group flex items-start gap-3 py-2 px-2 -mx-2 rounded-lg no-underline transition-all duration-200 ${
82
84
  isCurrent
83
85
  ? 'bg-accent/5'
@@ -93,7 +95,7 @@ export default function SeriesSidebar({ seriesSlug, seriesTitle, posts, currentS
93
95
  ? 'bg-accent/20 text-accent'
94
96
  : 'bg-muted/10 text-muted group-hover:bg-muted/20 group-hover:text-foreground'
95
97
  }`}>
96
- {String(index + 1).padStart(2, '0')}
98
+ {padNumber(index + 1)}
97
99
  </div>
98
100
 
99
101
  {/* Content */}
@@ -25,6 +25,7 @@ export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
25
25
  const { t } = useLanguage();
26
26
  const hasBoth = posts.length > 0 && flows.length > 0;
27
27
  const [activeTab, setActiveTab] = useState<Tab>('all');
28
+ const showSectionHeaders = hasBoth && activeTab === 'all';
28
29
 
29
30
  const showPosts = activeTab === 'all' || activeTab === 'posts';
30
31
  const showFlows = activeTab === 'all' || activeTab === 'flows';
@@ -65,12 +66,14 @@ export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
65
66
  {/* Posts section */}
66
67
  {showPosts && posts.length > 0 && (
67
68
  <div>
68
- <h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-6">
69
- {t('posts')}
70
- <span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
71
- {posts.length}
72
- </span>
73
- </h2>
69
+ {showSectionHeaders && (
70
+ <h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-6">
71
+ {t('posts')}
72
+ <span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
73
+ {posts.length}
74
+ </span>
75
+ </h2>
76
+ )}
74
77
  <PostList posts={posts} />
75
78
  </div>
76
79
  )}
@@ -78,12 +81,14 @@ export default function TagContentTabs({ posts, flows }: TagContentTabsProps) {
78
81
  {/* Flows section */}
79
82
  {showFlows && flows.length > 0 && (
80
83
  <div className={showPosts && posts.length > 0 ? 'mt-12' : ''}>
81
- <h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-4">
82
- {t('flow_notes')}
83
- <span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
84
- {flows.length}
85
- </span>
86
- </h2>
84
+ {showSectionHeaders && (
85
+ <h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-4">
86
+ {t('flow_notes')}
87
+ <span className="ml-1.5 font-mono font-normal normal-case tracking-normal text-muted/50">
88
+ {flows.length}
89
+ </span>
90
+ </h2>
91
+ )}
87
92
  <div>
88
93
  {flows.map(flow => (
89
94
  <FlowTimelineEntry
@@ -5,10 +5,17 @@ import { useLanguage } from './LanguageProvider';
5
5
 
6
6
  interface TagPageHeaderProps {
7
7
  tag: string;
8
+ postCount?: number;
9
+ flowCount?: number;
8
10
  }
9
11
 
10
- export default function TagPageHeader({ tag }: TagPageHeaderProps) {
11
- const { t } = useLanguage();
12
+ export default function TagPageHeader({ tag, postCount = 0, flowCount = 0 }: TagPageHeaderProps) {
13
+ const { t, tWith } = useLanguage();
14
+
15
+ const parts: string[] = [];
16
+ if (postCount > 0) parts.push(tWith(postCount === 1 ? 'tag_post_count_one' : 'tag_post_count', { count: postCount }));
17
+ if (flowCount > 0) parts.push(tWith(flowCount === 1 ? 'tag_flow_count_one' : 'tag_flow_count', { count: flowCount }));
18
+ const subtitle = parts.join(' · ');
12
19
 
13
20
  return (
14
21
  <>
@@ -26,6 +33,9 @@ export default function TagPageHeader({ tag }: TagPageHeaderProps) {
26
33
  <h1 className="text-3xl md:text-4xl font-serif font-bold text-heading">
27
34
  <span className="text-accent/50 mr-1">#</span>{tag}
28
35
  </h1>
36
+ {subtitle && (
37
+ <p className="mt-2 text-sm text-muted">{subtitle}</p>
38
+ )}
29
39
  </header>
30
40
  </>
31
41
  );
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from 'next/link';
4
4
  import { useState } from 'react';
5
- import { t, tWith } from '@/lib/i18n';
5
+ import { useLanguage } from './LanguageProvider';
6
6
  import { LuTag, LuX, LuSearch } from 'react-icons/lu';
7
7
 
8
8
  const INITIAL_SHOW = 12;
@@ -13,6 +13,7 @@ interface TagSidebarProps {
13
13
  }
14
14
 
15
15
  export default function TagSidebar({ tags, activeTag }: TagSidebarProps) {
16
+ const { t, tWith } = useLanguage();
16
17
  const [filter, setFilter] = useState('');
17
18
  const [expanded, setExpanded] = useState(false);
18
19
 
@@ -67,7 +68,7 @@ export default function TagSidebar({ tags, activeTag }: TagSidebarProps) {
67
68
  type="text"
68
69
  value={filter}
69
70
  onChange={(e) => setFilter(e.target.value)}
70
- placeholder="Filter…"
71
+ placeholder={t('filter_tags')}
71
72
  aria-label={t('filter_tags')}
72
73
  className="w-full pl-8 pr-7 py-1.5 text-xs bg-muted/5 border border-muted/15 rounded-lg outline-none focus:border-accent/40 text-foreground placeholder:text-muted/40 transition-colors"
73
74
  />
@@ -75,7 +75,7 @@ export default function TagsIndexClient({ tags }: TagsIndexClientProps) {
75
75
  type="text"
76
76
  value={filter}
77
77
  onChange={(e) => setFilter(e.target.value)}
78
- placeholder="Filter tags…"
78
+ placeholder={t('filter_tags')}
79
79
  aria-label={t('filter_tags')}
80
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
81
  />
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { Heading } from '@/lib/markdown';
5
+ import { useLanguage } from './LanguageProvider';
6
+ import { useActiveHeading } from '@/hooks/useActiveHeading';
7
+ import { scrollToHeading } from '@/lib/scroll-utils';
8
+
9
+ interface TocPanelProps {
10
+ headings: Heading[];
11
+ className?: string;
12
+ }
13
+
14
+ export default function TocPanel({ headings, className = '' }: TocPanelProps) {
15
+ const { t } = useLanguage();
16
+ const [collapsed, setCollapsed] = useState(false);
17
+ const activeHeadingId = useActiveHeading(headings);
18
+
19
+ if (headings.length === 0) return null;
20
+
21
+ return (
22
+ <nav aria-label={t('on_this_page')} className={className}>
23
+ <div className="flex items-center justify-between mb-3">
24
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
25
+ {t('on_this_page')}
26
+ </span>
27
+ <button
28
+ type="button"
29
+ onClick={() => setCollapsed(prev => !prev)}
30
+ className="text-muted hover:text-foreground transition-colors"
31
+ aria-expanded={!collapsed}
32
+ aria-label={collapsed ? t('toc_expand') : t('toc_collapse')}
33
+ >
34
+ <svg
35
+ className={`w-3.5 h-3.5 transition-transform duration-200 ${collapsed ? '' : 'rotate-180'}`}
36
+ fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
37
+ >
38
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
39
+ </svg>
40
+ </button>
41
+ </div>
42
+ {!collapsed && (
43
+ <ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
44
+ {headings.map(h => {
45
+ const isActive = h.id === activeHeadingId;
46
+ return (
47
+ <li key={h.id}>
48
+ <a
49
+ href={`#${h.id}`}
50
+ onClick={e => scrollToHeading(e, h.id)}
51
+ className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
52
+ isActive
53
+ ? 'text-accent font-medium border-l-2 border-accent -ml-px'
54
+ : 'text-foreground/70 hover:text-foreground'
55
+ }`}
56
+ style={{ paddingLeft: `${(h.level - 1) * 0.75}rem` }}
57
+ >
58
+ {h.text}
59
+ </a>
60
+ </li>
61
+ );
62
+ })}
63
+ </ul>
64
+ )}
65
+ </nav>
66
+ );
67
+ }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import type { Heading } from '@/lib/markdown';
5
+ import { useScrollY } from './useScrollY';
6
+
7
+ export function useActiveHeading(headings: Heading[], enabled = true): string {
8
+ const [activeId, setActiveId] = useState('');
9
+ const scrollY = useScrollY();
10
+
11
+ useEffect(() => {
12
+ if (!enabled || headings.length === 0) return;
13
+
14
+ const elements = headings
15
+ .map(h => document.getElementById(h.id))
16
+ .filter(Boolean) as HTMLElement[];
17
+
18
+ if (elements.length === 0) return;
19
+
20
+ const scrollPosition = scrollY + 100;
21
+ let current = elements[0];
22
+ for (const el of elements) {
23
+ if (el.offsetTop <= scrollPosition) current = el;
24
+ else break;
25
+ }
26
+
27
+ const rafId = requestAnimationFrame(() => { if (current) setActiveId(current.id); });
28
+ return () => cancelAnimationFrame(rafId);
29
+ }, [scrollY, headings, enabled]);
30
+
31
+ return activeId;
32
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import { useEffect, type RefObject } from 'react';
4
+
5
+ export function useSidebarAutoScroll(
6
+ sidebarRef: RefObject<HTMLElement | null>,
7
+ itemRef: RefObject<HTMLElement | null>,
8
+ dep: unknown,
9
+ ): void {
10
+ useEffect(() => {
11
+ if (itemRef.current && sidebarRef.current) {
12
+ const item = itemRef.current;
13
+ const sidebar = sidebarRef.current;
14
+ sidebar.scrollTop = item.offsetTop - sidebar.clientHeight / 2 + item.offsetHeight / 2;
15
+ }
16
+ // refs are stable; only re-run when dep changes
17
+ // eslint-disable-next-line react-hooks/exhaustive-deps
18
+ }, [dep]);
19
+ }
@@ -39,8 +39,13 @@ export const translations = {
39
39
  page_of_total: "Page {page} of {total}",
40
40
  series_subtitle: "{count} collections of curated knowledge.",
41
41
  series_subtitle_one: "1 collection of curated knowledge.",
42
- tags_subtitle: "{count} topics cultivated in this garden.",
43
- tags_subtitle_one: "1 topic cultivated in this garden.",
42
+ series_default_excerpt: "A growing collection of related thoughts.",
43
+ shuffle_series: "Shuffle series",
44
+ shuffle_posts: "Shuffle featured stories",
45
+ books_subtitle: "{count} long-form books and structured guides.",
46
+ books_subtitle_one: "1 long-form book and structured guide.",
47
+ tags_subtitle: "{count} topics spanning all articles and notes.",
48
+ tags_subtitle_one: "1 topic spanning all articles and notes.",
44
49
  tag_posts_found: "{count} articles found.",
45
50
  tag_posts_found_one: "1 article found.",
46
51
  about_title: "About Amytis",
@@ -61,6 +66,7 @@ export const translations = {
61
66
  flow: "Flow",
62
67
  recent_notes: "Recent Notes",
63
68
  all_flows: "All Flows",
69
+ no_posts: "No posts found.",
64
70
  no_flows: "No notes yet.",
65
71
  flow_subtitle: "{count} daily notes.",
66
72
  flows_in_year: "Notes in {year}",
@@ -130,6 +136,11 @@ export const translations = {
130
136
  browser_outdated: "Your browser is outdated and may not display this site correctly.",
131
137
  browser_update: "Update your browser",
132
138
  browser_dismiss: "Dismiss",
139
+ toc_expand: "Expand table of contents",
140
+ toc_collapse: "Collapse table of contents",
141
+ archive_description: "A complete chronological archive of all articles.",
142
+ tags_description: "Explore topics spanning all articles and flow notes.",
143
+ posts_description: "Browse all articles.",
133
144
  },
134
145
  zh: {
135
146
  home: "首页",
@@ -171,8 +182,13 @@ export const translations = {
171
182
  page_of_total: "第 {page} 页,共 {total} 页",
172
183
  series_subtitle: "{count} 个精选知识合集。",
173
184
  series_subtitle_one: "1 个精选知识合集。",
174
- tags_subtitle: "花园中培育了 {count} 个主题。",
175
- tags_subtitle_one: "花园中培育了 1 个主题。",
185
+ series_default_excerpt: "一个持续更新的文章合集。",
186
+ shuffle_series: "随机排列系列",
187
+ shuffle_posts: "随机排列精选文章",
188
+ books_subtitle: "共 {count} 部长篇书籍与结构化指南。",
189
+ books_subtitle_one: "1 部长篇书籍与结构化指南。",
190
+ tags_subtitle: "共 {count} 个主题,横跨全部文章与随笔。",
191
+ tags_subtitle_one: "1 个主题,横跨全部文章与随笔。",
176
192
  tag_posts_found: "找到 {count} 篇文章。",
177
193
  tag_posts_found_one: "找到 1 篇文章。",
178
194
  about_title: "关于 Amytis",
@@ -193,6 +209,7 @@ export const translations = {
193
209
  flow: "随笔",
194
210
  recent_notes: "近期随笔",
195
211
  all_flows: "全部随笔",
212
+ no_posts: "暂无文章。",
196
213
  no_flows: "暂无随笔。",
197
214
  flow_subtitle: "共 {count} 条随笔。",
198
215
  flows_in_year: "{year} 年随笔",
@@ -262,6 +279,11 @@ export const translations = {
262
279
  browser_outdated: "您的浏览器版本过旧,可能无法正常显示本站内容。",
263
280
  browser_update: "更新浏览器",
264
281
  browser_dismiss: "关闭",
282
+ toc_expand: "展开目录",
283
+ toc_collapse: "收起目录",
284
+ archive_description: "全部文章的时间轴归档。",
285
+ tags_description: "浏览全部文章与随笔的主题标签。",
286
+ posts_description: "浏览全部文章。",
265
287
  },
266
288
  };
267
289
 
@@ -1,10 +1,11 @@
1
- import Link from 'next/link';
2
1
  import { BookData, BookChapterData } from '@/lib/markdown';
3
2
  import MarkdownRenderer from '@/components/MarkdownRenderer';
4
3
  import BookSidebar from '@/components/BookSidebar';
5
4
  import BookMobileNav from '@/components/BookMobileNav';
5
+ import PrevNextNav from '@/components/PrevNextNav';
6
6
  import ReadingProgressBar from '@/components/ReadingProgressBar';
7
7
  import { t } from '@/lib/i18n';
8
+ import { getBookChapterUrl } from '@/lib/urls';
8
9
 
9
10
  interface BookLayoutProps {
10
11
  book: BookData;
@@ -27,7 +28,7 @@ export default function BookLayout({ book, chapter }: BookLayoutProps) {
27
28
  />
28
29
 
29
30
  {/* Main content */}
30
- <article className="min-w-0 max-w-3xl">
31
+ <article className="min-w-0 w-full max-w-3xl overflow-x-hidden">
31
32
  {/* Mobile nav */}
32
33
  <div className="lg:hidden mb-8">
33
34
  <BookMobileNav
@@ -64,44 +65,13 @@ export default function BookLayout({ book, chapter }: BookLayoutProps) {
64
65
  <MarkdownRenderer content={chapter.content} latex={chapter.latex} slug={chapter.isFolder ? `books/${book.slug}/${chapter.slug}` : `books/${book.slug}`} />
65
66
 
66
67
  {/* Prev/Next navigation */}
67
- <nav className="mt-16 pt-8 border-t border-muted/10 flex gap-4">
68
- {chapter.prevChapter ? (
69
- <Link
70
- href={`/books/${book.slug}/${chapter.prevChapter.id}`}
71
- className="flex-1 group flex items-center gap-3 py-4 px-5 rounded-xl bg-muted/5 hover:bg-muted/10 no-underline transition-colors"
72
- >
73
- <svg className="w-5 h-5 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
74
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
75
- </svg>
76
- <div className="min-w-0">
77
- <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-1">{t('prev')}</span>
78
- <span className="block text-sm font-medium text-foreground/80 group-hover:text-foreground truncate transition-colors">
79
- {chapter.prevChapter.title}
80
- </span>
81
- </div>
82
- </Link>
83
- ) : (
84
- <div className="flex-1" />
85
- )}
86
- {chapter.nextChapter ? (
87
- <Link
88
- href={`/books/${book.slug}/${chapter.nextChapter.id}`}
89
- className="flex-1 group flex items-center justify-end gap-3 py-4 px-5 rounded-xl bg-muted/5 hover:bg-muted/10 no-underline transition-colors text-right"
90
- >
91
- <div className="min-w-0">
92
- <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-1">{t('next')}</span>
93
- <span className="block text-sm font-medium text-foreground/80 group-hover:text-foreground truncate transition-colors">
94
- {chapter.nextChapter.title}
95
- </span>
96
- </div>
97
- <svg className="w-5 h-5 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
98
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
99
- </svg>
100
- </Link>
101
- ) : (
102
- <div className="flex-1" />
103
- )}
104
- </nav>
68
+ <div className="mt-16 pt-8 border-t border-muted/10">
69
+ <PrevNextNav
70
+ prev={chapter.prevChapter ? { href: getBookChapterUrl(book.slug, chapter.prevChapter.id), title: chapter.prevChapter.title } : null}
71
+ next={chapter.nextChapter ? { href: getBookChapterUrl(book.slug, chapter.nextChapter.id), title: chapter.nextChapter.title } : null}
72
+ size="lg"
73
+ />
74
+ </div>
105
75
  </article>
106
76
  </div>
107
77
  </div>
@@ -14,6 +14,7 @@ import AuthorCard from '@/components/AuthorCard';
14
14
  import ShareBar from '@/components/ShareBar';
15
15
  import { siteConfig } from '../../site.config';
16
16
  import { t } from '@/lib/i18n';
17
+ import { getPostUrl } from '@/lib/urls';
17
18
 
18
19
  interface PostLayoutProps {
19
20
  post: PostData;
@@ -30,7 +31,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
30
31
  const showToc = siteConfig.posts?.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
31
32
  const hasSeries = !!(post.series && seriesPosts && seriesPosts.length > 0);
32
33
  const showSidebar = showToc || hasSeries;
33
- const postUrl = `${siteConfig.baseUrl}/posts/${post.slug}`;
34
+ const postUrl = `${siteConfig.baseUrl}${getPostUrl(post)}`;
34
35
 
35
36
  return (
36
37
  <div className="layout-container">
@@ -52,7 +53,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
52
53
  />
53
54
  )}
54
55
 
55
- <article className="min-w-0 max-w-3xl mx-auto">
56
+ <article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
56
57
  <header className="mb-16 border-b border-muted/10 pb-8">
57
58
  {post.draft && (
58
59
  <div className="mb-4">
@@ -71,26 +72,34 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
71
72
  <span className="font-mono">{post.readingTime}</span>
72
73
  </div>
73
74
 
74
- <h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-6">
75
+ <h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-4">
75
76
  {post.title}
76
77
  </h1>
77
78
 
78
- <div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
79
- <span>{t('written_by')}</span>
80
- <div className="flex items-center gap-1">
81
- {post.authors.map((author, index) => (
82
- <span key={author} className="flex items-center">
83
- <Link
84
- href={`/authors/${getAuthorSlug(author)}`}
85
- className="text-foreground hover:text-accent no-underline transition-colors duration-200"
86
- >
87
- {author}
88
- </Link>
89
- {index < post.authors.length - 1 && <span className="mr-1">,</span>}
90
- </span>
91
- ))}
79
+ {post.subtitle && (
80
+ <p className="text-xl md:text-2xl font-serif italic text-muted leading-snug mb-6">
81
+ {post.subtitle}
82
+ </p>
83
+ )}
84
+
85
+ {siteConfig.posts?.authors?.showInHeader !== false && post.authors.length > 0 && (
86
+ <div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
87
+ <span>{t('written_by')}</span>
88
+ <div className="flex items-center gap-1">
89
+ {post.authors.map((author, index) => (
90
+ <span key={author} className="flex items-center">
91
+ <Link
92
+ href={`/authors/${getAuthorSlug(author)}`}
93
+ className="text-foreground hover:text-accent no-underline transition-colors duration-200"
94
+ >
95
+ {author}
96
+ </Link>
97
+ {index < post.authors.length - 1 && <span className="mr-1">,</span>}
98
+ </span>
99
+ ))}
100
+ </div>
92
101
  </div>
93
- </div>
102
+ )}
94
103
 
95
104
  {post.excerpt && (
96
105
  <p className="text-xl text-foreground font-serif italic leading-relaxed mb-8">
@@ -113,7 +122,11 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
113
122
  </div>
114
123
  )}
115
124
 
116
- <MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} slugRegistry={slugRegistry} />
125
+ <MarkdownRenderer content={post.content} latex={post.latex} slug={`posts/${post.slug}`} slugRegistry={slugRegistry} />
126
+
127
+ {siteConfig.posts?.authors?.showAuthorCard !== false && (
128
+ <AuthorCard authors={post.authors} />
129
+ )}
117
130
 
118
131
  {post.tags && post.tags.length > 0 && (
119
132
  <div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
@@ -124,10 +137,6 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
124
137
  </div>
125
138
  )}
126
139
 
127
- {post.externalLinks && post.externalLinks.length > 0 && (
128
- <ExternalLinks links={post.externalLinks} />
129
- )}
130
-
131
140
  <Backlinks backlinks={backlinks ?? []} />
132
141
 
133
142
  <ShareBar
@@ -136,11 +145,13 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
136
145
  className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
137
146
  />
138
147
 
139
- <AuthorCard authors={post.authors} />
148
+ <Comments slug={post.slug} postUrl={postUrl} />
140
149
 
141
- <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} />
150
+ {post.externalLinks && post.externalLinks.length > 0 && (
151
+ <ExternalLinks links={post.externalLinks} />
152
+ )}
142
153
 
143
- <Comments slug={post.slug} />
154
+ <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} />
144
155
 
145
156
  <RelatedPosts posts={relatedPosts || []} />
146
157
  </article>
@@ -36,16 +36,16 @@ export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayo
36
36
  {localeEntries.length > 0 ? (
37
37
  <LocaleSwitch>
38
38
  <div data-locale={defaultLocale}>
39
- <MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
39
+ <MarkdownRenderer content={post.content} latex={post.latex} slug={`posts/${post.slug}`} />
40
40
  </div>
41
41
  {localeEntries.map(([locale, data]) => (
42
42
  <div key={locale} data-locale={locale} style={{ display: 'none' }}>
43
- <MarkdownRenderer content={data.content} latex={post.latex} slug={post.slug} />
43
+ <MarkdownRenderer content={data.content} latex={post.latex} slug={`posts/${post.slug}`} />
44
44
  </div>
45
45
  ))}
46
46
  </LocaleSwitch>
47
47
  ) : (
48
- <MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
48
+ <MarkdownRenderer content={post.content} latex={post.latex} slug={`posts/${post.slug}`} />
49
49
  )}
50
50
  </>
51
51
  );
@@ -55,12 +55,12 @@ export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayo
55
55
  {showToc ? (
56
56
  <div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
57
57
  <PostSidebar currentSlug={post.slug} headings={post.headings} localeHeadings={localeHeadings} />
58
- <article className="min-w-0 max-w-3xl">
58
+ <article className="min-w-0 w-full max-w-3xl overflow-x-hidden">
59
59
  {articleContent}
60
60
  </article>
61
61
  </div>
62
62
  ) : (
63
- <article className="max-w-3xl mx-auto">
63
+ <article className="w-full max-w-3xl mx-auto overflow-x-hidden">
64
64
  {articleContent}
65
65
  </article>
66
66
  )}