@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
@@ -3,6 +3,7 @@
3
3
  import { useState, useMemo, type ReactNode } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { useLanguage } from '@/components/LanguageProvider';
6
+ import { padNumber } from '@/lib/format-utils';
6
7
 
7
8
  interface FlowCalendarSidebarProps {
8
9
  entryDates: string[];
@@ -14,6 +15,7 @@ interface FlowCalendarSidebarProps {
14
15
  }
15
16
 
16
17
  const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
18
+ const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
17
19
 
18
20
  export default function FlowCalendarSidebar({ entryDates, currentDate, tags, selectedTag, onTagSelect, breadcrumb }: FlowCalendarSidebarProps) {
19
21
  const { t } = useLanguage();
@@ -111,7 +113,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
111
113
  {days.map((day, i) => {
112
114
  if (day === null) return <div key={`empty-${i}`} />;
113
115
 
114
- const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
116
+ const dateStr = `${viewYear}-${padNumber(viewMonth + 1)}-${padNumber(day)}`;
115
117
  const hasEntry = entrySet.has(dateStr);
116
118
  const isToday = dateStr === todayStr;
117
119
  const isCurrent = dateStr === currentDate;
@@ -122,7 +124,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
122
124
  return (
123
125
  <Link
124
126
  key={dateStr}
125
- href={`/flows/${viewYear}/${String(viewMonth + 1).padStart(2, '0')}/${String(day).padStart(2, '0')}`}
127
+ href={`/flows/${viewYear}/${padNumber(viewMonth + 1)}/${padNumber(day)}`}
126
128
  className={`${baseClasses} bg-accent text-white font-bold no-underline`}
127
129
  >
128
130
  {day}
@@ -134,7 +136,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
134
136
  return (
135
137
  <Link
136
138
  key={dateStr}
137
- href={`/flows/${viewYear}/${String(viewMonth + 1).padStart(2, '0')}/${String(day).padStart(2, '0')}`}
139
+ href={`/flows/${viewYear}/${padNumber(viewMonth + 1)}/${padNumber(day)}`}
138
140
  className={`${baseClasses} text-foreground hover:bg-accent/10 font-medium no-underline ${isToday ? 'ring-1 ring-accent' : ''}`}
139
141
  >
140
142
  {day}
@@ -203,12 +205,12 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
203
205
  return (
204
206
  <Link
205
207
  key={m}
206
- href={`/flows/${year}/${String(m).padStart(2, '0')}`}
208
+ href={`/flows/${year}/${padNumber(m)}`}
207
209
  className={`flex items-center justify-between text-xs no-underline px-1 py-0.5 rounded hover:bg-accent/10 ${
208
210
  isCurrentMonth ? 'text-accent font-medium' : 'text-muted'
209
211
  }`}
210
212
  >
211
- <span>{String(m).padStart(2, '0')}</span>
213
+ <span>{MONTH_NAMES[m - 1]}</span>
212
214
  <span className="text-[10px]">{months[m]}</span>
213
215
  </Link>
214
216
  );
@@ -230,17 +232,18 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
230
232
  <div className="flex flex-wrap gap-1.5">
231
233
  {Object.entries(tags)
232
234
  .sort((a, b) => b[1] - a[1])
233
- .map(([tag]) => (
235
+ .map(([tag, count]) => (
234
236
  <button
235
237
  key={tag}
236
238
  onClick={() => onTagSelect?.(tag)}
237
- className={`inline-flex items-center px-2 py-0.5 text-xs rounded-full border transition-colors ${
239
+ className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border transition-colors ${
238
240
  selectedTag === tag
239
241
  ? 'bg-accent text-white border-accent'
240
242
  : 'border-muted/20 text-muted hover:border-accent hover:text-accent'
241
243
  }`}
242
244
  >
243
245
  {tag}
246
+ <span className={`text-[10px] ${selectedTag === tag ? 'opacity-80' : 'opacity-60'}`}>{count}</span>
244
247
  </button>
245
248
  ))}
246
249
  </div>
@@ -16,6 +16,7 @@ interface FlowItem {
16
16
 
17
17
  interface FlowContentProps {
18
18
  flows: FlowItem[];
19
+ allFlows?: FlowItem[];
19
20
  entryDates: string[];
20
21
  tags: Record<string, number>;
21
22
  currentDate?: string;
@@ -27,19 +28,22 @@ interface FlowContentProps {
27
28
  breadcrumb?: ReactNode;
28
29
  }
29
30
 
30
- export default function FlowContent({ flows, entryDates, tags, currentDate, pagination, breadcrumb }: FlowContentProps) {
31
+ export default function FlowContent({ flows, allFlows, entryDates, tags, currentDate, pagination, breadcrumb }: FlowContentProps) {
31
32
  const { t } = useLanguage();
32
33
  const [selectedTag, setSelectedTag] = useState<string | null>(null);
33
34
 
34
35
  const filteredFlows = useMemo(() => {
35
36
  if (!selectedTag) return flows;
36
- return flows.filter(f => f.tags.includes(selectedTag));
37
- }, [flows, selectedTag]);
37
+ const source = allFlows ?? flows;
38
+ return source.filter(f => f.tags.includes(selectedTag));
39
+ }, [flows, allFlows, selectedTag]);
38
40
 
39
41
  function handleTagSelect(tag: string) {
40
42
  setSelectedTag(prev => (prev === tag ? null : tag));
41
43
  }
42
44
 
45
+ const hasTags = tags && Object.keys(tags).length > 0;
46
+
43
47
  return (
44
48
  <div className="flex gap-10">
45
49
  <FlowCalendarSidebar
@@ -52,10 +56,34 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
52
56
  />
53
57
 
54
58
  <div className="flex-1 min-w-0">
59
+ {/* Mobile tag filter strip */}
60
+ {hasTags && (
61
+ <div className="lg:hidden mb-4">
62
+ <div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
63
+ {Object.entries(tags)
64
+ .sort((a, b) => b[1] - a[1])
65
+ .map(([tag, count]) => (
66
+ <button
67
+ key={tag}
68
+ onClick={() => handleTagSelect(tag)}
69
+ className={`shrink-0 inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-full border transition-colors ${
70
+ selectedTag === tag
71
+ ? 'bg-accent text-white border-accent'
72
+ : 'border-muted/20 text-muted hover:border-accent hover:text-accent'
73
+ }`}
74
+ >
75
+ {tag}
76
+ <span className={`text-[10px] ${selectedTag === tag ? 'opacity-80' : 'opacity-60'}`}>{count}</span>
77
+ </button>
78
+ ))}
79
+ </div>
80
+ </div>
81
+ )}
82
+
55
83
  {selectedTag && (
56
84
  <div className="flex items-center gap-2 mb-4 text-sm text-muted">
57
85
  <span>
58
- {filteredFlows.length} / {flows.length}
86
+ {filteredFlows.length} / {(allFlows ?? flows).length}
59
87
  </span>
60
88
  <button
61
89
  onClick={() => setSelectedTag(null)}
@@ -82,7 +110,7 @@ export default function FlowContent({ flows, entryDates, tags, currentDate, pagi
82
110
  </div>
83
111
  )}
84
112
 
85
- {pagination && pagination.totalPages > 1 && (
113
+ {!selectedTag && pagination && pagination.totalPages > 1 && (
86
114
  <div className="mt-12">
87
115
  <Pagination
88
116
  currentPage={pagination.currentPage}
@@ -12,10 +12,10 @@ export default function Footer() {
12
12
 
13
13
  return (
14
14
  <footer className="bg-muted/5 border-t border-muted/10 mt-auto">
15
- <div className="max-w-6xl mx-auto px-6 py-16">
16
- <div className="grid grid-cols-1 lg:grid-cols-4 gap-12 mb-12">
15
+ <div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
16
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
17
17
  {/* Brand */}
18
- <div className="lg:col-span-2">
18
+ <div className="col-span-2 lg:col-span-2">
19
19
  <Link href="/" className="flex items-center gap-2 mb-4 group no-underline">
20
20
  <svg
21
21
  viewBox="0 0 32 32"
@@ -89,10 +89,32 @@ export default function Footer() {
89
89
  {/* Bottom Bar */}
90
90
  <div className="pt-8 border-t border-muted/10 flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-muted">
91
91
  <span>{resolveLocaleValue(siteConfig.footerText, language)}</span>
92
- <div className="flex items-center gap-6">
93
- <LanguageSwitch variant="text" />
94
- <span className="opacity-20">|</span>
92
+ <div className="flex flex-wrap items-center gap-x-6 gap-y-2">
93
+ {siteConfig.i18n.enabled !== false && siteConfig.i18n.locales.length >= 2 && (
94
+ <>
95
+ <LanguageSwitch variant="text" />
96
+ <span className="opacity-20">|</span>
97
+ </>
98
+ )}
95
99
  <Link href="/privacy" className="hover:text-foreground transition-colors no-underline">{t('privacy')}</Link>
100
+ {siteConfig.footer?.bottomLinks?.map((item, index) => {
101
+ const label = resolveLocaleValue(item.text, language);
102
+ const isInternal = item.url?.startsWith('/');
103
+ return (
104
+ <span key={`${index}:${item.url ?? label}`} className="flex items-center gap-x-6">
105
+ <span className="opacity-20">|</span>
106
+ {item.url ? (
107
+ isInternal ? (
108
+ <Link href={item.url} className="hover:text-foreground transition-colors no-underline">{label}</Link>
109
+ ) : (
110
+ <a href={item.url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors no-underline">{label}</a>
111
+ )
112
+ ) : (
113
+ <span>{label}</span>
114
+ )}
115
+ </span>
116
+ );
117
+ })}
96
118
  {siteConfig.footer?.builtWith?.show && (() => {
97
119
  const cfg = siteConfig.footer.builtWith;
98
120
  const label = cfg.text ? resolveLocaleValue(cfg.text, language) : t('built_with');
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef } from 'react';
3
+ import Link from 'next/link';
4
4
  import { useLanguage } from '@/components/LanguageProvider';
5
5
  import { resolveLocaleValue } from '@/lib/i18n';
6
6
 
@@ -10,92 +10,49 @@ interface HeroProps {
10
10
  tagline: LocaleValue;
11
11
  title: LocaleValue;
12
12
  subtitle: LocaleValue;
13
+ postCount?: number;
14
+ seriesCount?: number;
15
+ bookCount?: number;
16
+ flowCount?: number;
17
+ featureNames?: {
18
+ flow?: LocaleValue;
19
+ posts?: LocaleValue;
20
+ series?: LocaleValue;
21
+ books?: LocaleValue;
22
+ };
23
+ }
24
+
25
+ interface StatItem {
26
+ href: string;
27
+ count: number;
28
+ label: string;
13
29
  }
14
30
 
15
- export default function Hero({ tagline, title, subtitle }: HeroProps) {
31
+ export default function Hero({ tagline, title, subtitle, postCount, seriesCount, bookCount, flowCount, featureNames }: HeroProps) {
16
32
  const { language } = useLanguage();
17
33
  const resolvedTagline = resolveLocaleValue(tagline, language);
18
34
  const resolvedTitle = resolveLocaleValue(title, language);
19
35
  const resolvedSubtitle = resolveLocaleValue(subtitle, language);
20
- const [isVisible, setIsVisible] = useState<boolean | null>(null);
21
- const visibleRef = useRef(false);
22
-
23
- useEffect(() => {
24
- const saved = localStorage.getItem('amytis-hero-visible');
25
- const visible = saved !== 'false';
26
- // Use requestAnimationFrame to avoid cascading render lint error
27
- const rafId = requestAnimationFrame(() => {
28
- setIsVisible(visible);
29
- visibleRef.current = visible;
30
- });
31
- return () => cancelAnimationFrame(rafId);
32
- }, []);
33
-
34
- // Auto-collapse on scroll or navigation away
35
- useEffect(() => {
36
- if (!isVisible) return;
37
-
38
- const handleScroll = () => {
39
- if (window.scrollY > 400) {
40
- setIsVisible(false);
41
- visibleRef.current = false;
42
- localStorage.setItem('amytis-hero-visible', 'false');
43
- }
44
- };
45
-
46
- window.addEventListener('scroll', handleScroll, { passive: true });
47
- return () => {
48
- window.removeEventListener('scroll', handleScroll);
49
- // On unmount (navigation away), hide for next visit
50
- if (visibleRef.current) {
51
- localStorage.setItem('amytis-hero-visible', 'false');
52
- }
53
- };
54
- }, [isVisible]);
55
36
 
56
- const handleCollapse = () => {
57
- setIsVisible(false);
58
- visibleRef.current = false;
59
- localStorage.setItem('amytis-hero-visible', 'false');
60
- };
61
-
62
- const handleExpand = () => {
63
- setIsVisible(true);
64
- visibleRef.current = true;
65
- localStorage.setItem('amytis-hero-visible', 'true');
66
- };
37
+ const label = (key: keyof NonNullable<typeof featureNames>, fallback: string) =>
38
+ featureNames?.[key] ? resolveLocaleValue(featureNames[key]!, language) : fallback;
67
39
 
68
- // Avoid layout shift on first render
69
- if (isVisible === null) {
70
- return <div className="min-h-[60vh]" />;
71
- }
72
-
73
- if (!isVisible) {
74
- return (
75
- <div className="py-10">
76
- <button
77
- onClick={handleExpand}
78
- className="mx-auto flex items-center justify-center gap-4 group cursor-pointer"
79
- >
80
- <span className="h-px w-12 bg-muted/20 group-hover:bg-accent/40 transition-colors" />
81
- <span className="text-xs font-sans font-bold uppercase tracking-[0.3em] text-muted/40 group-hover:text-accent/80 transition-colors">
82
- {resolvedTagline}
83
- </span>
84
- <span className="h-px w-12 bg-muted/20 group-hover:bg-accent/40 transition-colors" />
85
- </button>
86
- </div>
87
- );
88
- }
40
+ const stats = [
41
+ flowCount != null ? { href: '#recent-flows', count: flowCount, label: label('flow', 'Flow') } : null,
42
+ postCount != null ? { href: '#featured-posts', count: postCount, label: label('posts', 'Posts') } : null,
43
+ seriesCount != null ? { href: '#featured-series', count: seriesCount, label: label('series', 'Series') } : null,
44
+ bookCount != null ? { href: '#featured-books', count: bookCount, label: label('books', 'Books') } : null,
45
+ ].filter((s): s is StatItem => s !== null);
89
46
 
90
47
  return (
91
- <header className="relative py-24 md:py-40 flex flex-col items-center justify-center text-center max-w-6xl mx-auto min-h-[60vh] px-6">
48
+ <header className="relative py-12 md:py-20 flex flex-col items-center justify-center text-center max-w-6xl mx-auto min-h-[40vh] px-6">
92
49
  <div className="mb-8 flex items-center justify-center animate-fade-in">
93
- <span className="h-px w-12 bg-accent/30 mr-4"></span>
94
- <span className="text-xs font-sans font-bold uppercase tracking-[0.3em] text-accent/80">{resolvedTagline}</span>
95
- <span className="h-px w-12 bg-accent/30 ml-4"></span>
50
+ <span className="h-px w-12 bg-accent/30 mr-4"></span>
51
+ <span className="text-xs font-sans font-bold uppercase tracking-[0.3em] text-accent/80">{resolvedTagline}</span>
52
+ <span className="h-px w-12 bg-accent/30 ml-4"></span>
96
53
  </div>
97
54
 
98
- <h1 className="text-4xl md:text-5xl lg:text-6xl font-serif font-medium text-heading leading-[1.1] tracking-tight mb-10 text-balance animate-slide-up">
55
+ <h1 className="text-4xl md:text-5xl lg:text-6xl font-serif font-medium text-heading leading-[1.1] tracking-tight mb-6 text-balance animate-slide-up">
99
56
  {resolvedTitle}
100
57
  </h1>
101
58
 
@@ -103,24 +60,19 @@ export default function Hero({ tagline, title, subtitle }: HeroProps) {
103
60
  {resolvedSubtitle}
104
61
  </p>
105
62
 
106
- {/* Collapse button */}
107
- <button
108
- onClick={handleCollapse}
109
- className="absolute top-4 right-4 text-muted/30 hover:text-accent transition-colors p-2"
110
- aria-label="Collapse intro"
111
- >
112
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
113
- <line x1="18" y1="6" x2="6" y2="18" />
114
- <line x1="6" y1="6" x2="18" y2="18" />
115
- </svg>
116
- </button>
117
-
118
- {/* Scroll Indicator */}
119
- <div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce text-muted/30">
120
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
121
- <path d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
122
- </svg>
123
- </div>
63
+ {stats.length > 0 && (
64
+ <div className="mt-8 flex flex-wrap justify-center items-center gap-x-1 gap-y-2 text-sm font-mono animate-slide-up animation-delay-200">
65
+ {stats.map((stat, i) => (
66
+ <span key={stat.href} className="inline-flex items-center gap-1">
67
+ {i > 0 && <span className="text-muted/30 mx-2" aria-hidden>·</span>}
68
+ <Link href={stat.href} className="group no-underline">
69
+ <span className="font-semibold text-heading group-hover:text-accent transition-colors duration-200">{stat.count}</span>
70
+ <span className="text-muted ml-1 group-hover:text-accent/70 transition-colors duration-200">{stat.label}</span>
71
+ </Link>
72
+ </span>
73
+ ))}
74
+ </div>
75
+ )}
124
76
  </header>
125
77
  );
126
78
  }
@@ -0,0 +1,56 @@
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
+ export default function InlineBookToc({ headings }: { headings: Heading[] }) {
10
+ const { t } = useLanguage();
11
+ const [collapsed, setCollapsed] = useState(false);
12
+ const activeHeadingId = useActiveHeading(headings);
13
+
14
+ if (headings.length === 0) return null;
15
+
16
+ return (
17
+ <div className="mt-1.5 mb-1 ml-3">
18
+ <button
19
+ onClick={() => setCollapsed(prev => !prev)}
20
+ className="flex items-center gap-1.5 text-[11px] font-sans font-medium uppercase tracking-wider text-muted hover:text-foreground transition-colors mb-1.5 pl-3"
21
+ >
22
+ <svg
23
+ className={`w-3 h-3 flex-shrink-0 transition-transform duration-200 ${collapsed ? '' : 'rotate-180'}`}
24
+ fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
25
+ >
26
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
27
+ </svg>
28
+ {t('on_this_page')}
29
+ </button>
30
+ {!collapsed && (
31
+ <ul className="space-y-0.5 border-l border-muted/15 animate-slide-down">
32
+ {headings.map(h => {
33
+ const isActive = h.id === activeHeadingId;
34
+ return (
35
+ <li key={h.id}>
36
+ <a
37
+ href={`#${h.id}`}
38
+ onClick={e => scrollToHeading(e, h.id)}
39
+ className={`block py-1 text-[13px] leading-snug no-underline transition-colors duration-200 ${
40
+ h.level === 3 ? 'pl-6' : 'pl-3'
41
+ } ${
42
+ isActive
43
+ ? 'text-accent font-medium border-l-2 border-accent -ml-px'
44
+ : 'text-foreground/70 hover:text-foreground'
45
+ }`}
46
+ >
47
+ {h.text}
48
+ </a>
49
+ </li>
50
+ );
51
+ })}
52
+ </ul>
53
+ )}
54
+ </div>
55
+ );
56
+ }
@@ -217,6 +217,32 @@ export default function KnowledgeGraph() {
217
217
  node.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
218
218
  });
219
219
 
220
+ // After simulation cools, fit all nodes into view
221
+ simulation.on('end', () => {
222
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
223
+ filteredNodes.forEach(d => {
224
+ const r = nodeRadius(d.connections);
225
+ x0 = Math.min(x0, (d.x ?? 0) - r);
226
+ y0 = Math.min(y0, (d.y ?? 0) - r);
227
+ x1 = Math.max(x1, (d.x ?? 0) + r);
228
+ y1 = Math.max(y1, (d.y ?? 0) + r);
229
+ });
230
+ if (!isFinite(x0) || x1 <= x0 || y1 <= y0) return;
231
+
232
+ const padding = 40;
233
+ const scale = Math.min(
234
+ (width - padding * 2) / (x1 - x0),
235
+ (height - padding * 2) / (y1 - y0)
236
+ );
237
+ const cx = (x0 + x1) / 2;
238
+ const cy = (y0 + y1) / 2;
239
+ const tx = width / 2 - scale * cx;
240
+ const ty = height / 2 - scale * cy;
241
+
242
+ svgEl.transition().duration(600)
243
+ .call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
244
+ });
245
+
220
246
  return () => {
221
247
  simulation.stop();
222
248
  tooltip.remove();
@@ -15,15 +15,19 @@ interface LanguageContextType {
15
15
 
16
16
  const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
17
17
 
18
+ const isI18nEnabled = siteConfig.i18n.enabled !== false && siteConfig.i18n.locales.length >= 2;
19
+
18
20
  export function LanguageProvider({ children }: { children: React.ReactNode }) {
19
21
  // Always initialize with site default to match server-side rendering
20
22
  const [language, setLanguageState] = useState<Language>(siteConfig.i18n.defaultLocale as Language);
21
- const [isHydrated, setIsHydrated] = useState(false);
23
+ // When i18n is disabled there is nothing to hydrate — mark as ready immediately
24
+ const [isHydrated, setIsHydrated] = useState(!isI18nEnabled);
22
25
 
23
26
  useEffect(() => {
27
+ if (!isI18nEnabled) return;
24
28
  // Only access localStorage and browser language after component is mounted (client-side)
25
29
  const savedLang = localStorage.getItem('amytis-language') as Language;
26
-
30
+
27
31
  // Use requestAnimationFrame to avoid cascading render lint error
28
32
  const rafId = requestAnimationFrame(() => {
29
33
  if (savedLang && translations[savedLang]) {
@@ -35,6 +39,7 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) {
35
39
  }, []);
36
40
 
37
41
  const setLanguage = (lang: Language) => {
42
+ if (!isI18nEnabled) return;
38
43
  setLanguageState(lang);
39
44
  localStorage.setItem('amytis-language', lang);
40
45
  };
@@ -25,7 +25,7 @@ export default function LanguageSwitch({ variant = 'pill' }: LanguageSwitchProps
25
25
  const { language, setLanguage, isHydrated } = useLanguage();
26
26
  const locales = siteConfig.i18n.locales;
27
27
 
28
- if (locales.length < 2) return null;
28
+ if (siteConfig.i18n.enabled === false || locales.length < 2) return null;
29
29
 
30
30
  // SSR placeholder — reserve space to avoid layout shift
31
31
  if (!isHydrated) {
@@ -4,6 +4,7 @@ import Link from 'next/link';
4
4
  import PostList from './PostList';
5
5
  import { useLanguage } from './LanguageProvider';
6
6
  import { PostData } from '@/lib/markdown';
7
+ import { getPostsListUrl } from '@/lib/urls';
7
8
 
8
9
  interface LatestWritingSectionProps {
9
10
  posts: PostData[];
@@ -14,32 +15,21 @@ export default function LatestWritingSection({ posts, totalCount }: LatestWritin
14
15
  const { t, tWith } = useLanguage();
15
16
 
16
17
  return (
17
- <section>
18
+ <section id="latest-posts">
18
19
  <div className="flex items-center justify-between mb-8">
19
20
  <h2 className="text-3xl font-serif font-bold text-heading">{t('latest_writing')}</h2>
21
+ <Link
22
+ href={getPostsListUrl()}
23
+ className="text-sm text-muted hover:text-accent transition-colors no-underline inline-flex items-center gap-1"
24
+ >
25
+ {tWith('view_all_posts', { count: totalCount })}
26
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
27
+ <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
28
+ </svg>
29
+ </Link>
20
30
  </div>
21
31
 
22
- <PostList posts={posts} showTags={false} />
23
-
24
- {totalCount > posts.length && (
25
- <div className="mt-12 text-center">
26
- <Link
27
- href="/posts"
28
- className="group inline-flex items-center gap-2 px-6 py-3 rounded-full border border-muted/20 bg-muted/5 text-sm font-medium text-muted hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all duration-300 no-underline"
29
- >
30
- <span>{tWith('view_all_posts', { count: totalCount })}</span>
31
- <svg
32
- className="w-4 h-4 transform group-hover:translate-x-0.5 transition-transform"
33
- fill="none"
34
- viewBox="0 0 24 24"
35
- stroke="currentColor"
36
- strokeWidth={2}
37
- >
38
- <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
39
- </svg>
40
- </Link>
41
- </div>
42
- )}
32
+ <PostList posts={posts} showTags={false} excerptLines={1} />
43
33
  </section>
44
34
  );
45
35
  }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import MarkdownRenderer from "./MarkdownRenderer";
4
+
5
+ describe("MarkdownRenderer", () => {
6
+ describe("image rendering", () => {
7
+ test("uses ExportedImage with fallback dimensions for local images without known dimensions", () => {
8
+ const content = "![alt text](/images/local.jpg)";
9
+ const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
10
+ // ExportedImage is used with 1200x900 fallback when no dimensions are known
11
+ expect(html).toContain('width="1200"');
12
+ expect(html).toContain('height="900"');
13
+ // style override ensures the image renders at its natural size
14
+ expect(html).toContain('width:100%');
15
+ });
16
+
17
+ test("uses plain img for external images", () => {
18
+ const content = "![alt text](https://example.com/image.jpg)";
19
+ const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
20
+ expect(html).toContain('src="https://example.com/image.jpg"');
21
+ // No fallback dimensions — external images are not processed by ExportedImage
22
+ expect(html).not.toContain('width="1200"');
23
+ });
24
+ });
25
+
26
+ test("adds horizontal overflow containment while preserving code scrolling", () => {
27
+ const content = [
28
+ "## Example",
29
+ "",
30
+ "```bash",
31
+ "echo this-is-a-very-long-line-that-should-scroll-inside-the-code-block",
32
+ "```",
33
+ ].join("\n");
34
+
35
+ const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
36
+
37
+ expect(html).toContain("overflow-x-hidden");
38
+ expect(html).toContain("not-prose w-full min-w-0 max-w-full");
39
+ expect(html).toContain("overflow-x-auto");
40
+ });
41
+ });