@hutusi/amytis 1.5.6 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/CLAUDE.md +3 -2
  3. package/GEMINI.md +13 -6
  4. package/README.md +1 -1
  5. package/TODO.md +21 -76
  6. package/bun.lock +18 -3
  7. package/content/about.mdx +1 -0
  8. package/content/about.zh.mdx +21 -0
  9. package/content/flows/2026/02/20.md +16 -0
  10. package/content/links.mdx +42 -0
  11. package/content/links.zh.mdx +41 -0
  12. package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
  13. package/content/posts/multimedia-showcase/index.mdx +261 -0
  14. package/content/privacy.mdx +32 -0
  15. package/content/privacy.zh.mdx +32 -0
  16. package/docs/ARCHITECTURE.md +11 -2
  17. package/docs/CONTRIBUTING.md +4 -2
  18. package/docs/deployment.md +9 -1
  19. package/eslint.config.mjs +2 -0
  20. package/package.json +5 -4
  21. package/public/next-image-export-optimizer-hashes.json +0 -3
  22. package/scripts/copy-assets.ts +1 -1
  23. package/site.config.ts +126 -44
  24. package/src/app/[slug]/page.tsx +0 -10
  25. package/src/app/archive/page.tsx +38 -10
  26. package/src/app/books/[slug]/page.tsx +18 -0
  27. package/src/app/flows/[year]/[month]/[day]/page.tsx +21 -4
  28. package/src/app/layout.tsx +48 -21
  29. package/src/app/page.tsx +135 -72
  30. package/src/app/posts/[slug]/page.tsx +6 -12
  31. package/src/app/search.json/route.ts +4 -0
  32. package/src/app/series/[slug]/page.tsx +18 -0
  33. package/src/app/subscribe/page.tsx +17 -0
  34. package/src/app/tags/[tag]/page.tsx +9 -26
  35. package/src/app/tags/page.tsx +3 -8
  36. package/src/components/AuthorCard.tsx +43 -0
  37. package/src/components/Comments.tsx +20 -4
  38. package/src/components/ExternalLinks.tsx +6 -2
  39. package/src/components/Footer.tsx +35 -26
  40. package/src/components/LanguageProvider.tsx +0 -5
  41. package/src/components/LanguageSwitch.tsx +117 -6
  42. package/src/components/LocaleSwitch.tsx +33 -0
  43. package/src/components/Navbar.tsx +31 -8
  44. package/src/components/PostNavigation.tsx +55 -0
  45. package/src/components/PostSidebar.tsx +172 -126
  46. package/src/components/ReadingProgressBar.tsx +6 -21
  47. package/src/components/RelatedPosts.tsx +1 -1
  48. package/src/components/Search.tsx +420 -70
  49. package/src/components/SelectedBooksSection.tsx +12 -6
  50. package/src/components/ShareBar.tsx +115 -0
  51. package/src/components/SimpleLayoutHeader.tsx +5 -14
  52. package/src/components/SubscribePage.tsx +298 -0
  53. package/src/components/TagContentTabs.tsx +103 -0
  54. package/src/components/TagPageHeader.tsx +7 -13
  55. package/src/components/TagSidebar.tsx +142 -0
  56. package/src/components/TagsIndexClient.tsx +156 -0
  57. package/src/hooks/useScrollY.ts +41 -0
  58. package/src/i18n/translations.ts +110 -2
  59. package/src/layouts/PostLayout.tsx +34 -7
  60. package/src/layouts/SimpleLayout.tsx +53 -15
  61. package/src/lib/markdown.ts +71 -15
  62. package/src/lib/search-utils.test.ts +163 -0
  63. package/src/lib/search-utils.ts +39 -0
  64. package/src/types/pagefind.d.ts +42 -0
  65. package/src/components/TableOfContents.tsx +0 -158
@@ -68,6 +68,7 @@ export interface PostData {
68
68
  readingTime: string;
69
69
  content: string;
70
70
  headings: Heading[];
71
+ contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
71
72
  }
72
73
 
73
74
  export function calculateReadingTime(content: string): string {
@@ -238,7 +239,7 @@ export function getAllPosts(): PostData[] {
238
239
 
239
240
  if (match) {
240
241
  dateFromFileName = match[1];
241
- if (siteConfig.includeDateInUrl) {
242
+ if (siteConfig.posts?.includeDateInUrl) {
242
243
  slug = rawName;
243
244
  } else {
244
245
  slug = match[2];
@@ -266,7 +267,7 @@ export function getAllPosts(): PostData[] {
266
267
  let sDate = undefined;
267
268
  if (sMatch) {
268
269
  sDate = sMatch[1];
269
- sSlug = siteConfig.includeDateInUrl ? sRawName : sMatch[2];
270
+ sSlug = siteConfig.posts?.includeDateInUrl ? sRawName : sMatch[2];
270
271
  }
271
272
 
272
273
  allPostsData.push(parseMarkdownFile(
@@ -294,7 +295,7 @@ export function getAllPosts(): PostData[] {
294
295
 
295
296
  if (sMatch) {
296
297
  sDate = sMatch[1];
297
- sSlug = siteConfig.includeDateInUrl ? sItem.name : sMatch[2];
298
+ sSlug = siteConfig.posts?.includeDateInUrl ? sItem.name : sMatch[2];
298
299
  }
299
300
 
300
301
  allPostsData.push(parseMarkdownFile(
@@ -338,7 +339,7 @@ export function getAllPosts(): PostData[] {
338
339
  return false;
339
340
  }
340
341
 
341
- if (!siteConfig.showFuturePosts) {
342
+ if (!siteConfig.posts?.showFuturePosts) {
342
343
  const postDate = new Date(post.date);
343
344
  const now = new Date();
344
345
  if (postDate > now) return false;
@@ -396,7 +397,7 @@ function findPostFile(name: string, targetSlug: string): PostData | null {
396
397
  export function getPostBySlug(slug: string): PostData | null {
397
398
  let post: PostData | null = null;
398
399
 
399
- if (siteConfig.includeDateInUrl) {
400
+ if (siteConfig.posts?.includeDateInUrl) {
400
401
  post = findPostFile(slug, slug);
401
402
  } else {
402
403
  post = findPostFile(slug, slug);
@@ -445,7 +446,7 @@ export function getPostBySlug(slug: string): PostData | null {
445
446
  return null;
446
447
  }
447
448
 
448
- if (!siteConfig.showFuturePosts) {
449
+ if (!siteConfig.posts?.showFuturePosts) {
449
450
  const postDate = new Date(post.date);
450
451
  const now = new Date();
451
452
  if (postDate > now) return null;
@@ -453,18 +454,53 @@ export function getPostBySlug(slug: string): PostData | null {
453
454
  return post;
454
455
  }
455
456
 
457
+ /**
458
+ * Load the content and frontmatter of a locale variant file, e.g. about.zh.mdx.
459
+ * Returns null when the file does not exist or cannot be parsed.
460
+ */
461
+ function loadLocaleContent(slug: string, locale: string): { content: string; title?: string; excerpt?: string; headings?: Heading[] } | null {
462
+ for (const ext of ['.mdx', '.md']) {
463
+ const filePath = path.join(pagesDirectory, `${slug}.${locale}${ext}`);
464
+ if (fs.existsSync(filePath)) {
465
+ try {
466
+ const { data, content } = matter(fs.readFileSync(filePath, 'utf8'));
467
+ const body = content.replace(/^\s*#\s+[^\n]+/, '').trim();
468
+ return {
469
+ content: body,
470
+ title: typeof data.title === 'string' ? data.title : undefined,
471
+ excerpt: typeof data.excerpt === 'string' ? data.excerpt : undefined,
472
+ headings: getHeadings(body),
473
+ };
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+ }
479
+ return null;
480
+ }
481
+
482
+ /**
483
+ * Collect contentLocales for all non-default locales that have a variant file.
484
+ */
485
+ function attachContentLocales(page: PostData, slug: string): PostData {
486
+ const defaultLocale = siteConfig.i18n.defaultLocale;
487
+ const otherLocales = siteConfig.i18n.locales.filter(l => l !== defaultLocale);
488
+ const contentLocales: NonNullable<PostData['contentLocales']> = {};
489
+ for (const locale of otherLocales) {
490
+ const localeData = loadLocaleContent(slug, locale);
491
+ if (localeData !== null) contentLocales[locale] = localeData;
492
+ }
493
+ return Object.keys(contentLocales).length > 0 ? { ...page, contentLocales } : page;
494
+ }
495
+
456
496
  export function getPageBySlug(slug: string): PostData | null {
457
497
  try {
458
498
  let fullPath = path.join(pagesDirectory, `${slug}.mdx`);
459
499
  if (!fs.existsSync(fullPath)) {
460
500
  fullPath = path.join(pagesDirectory, `${slug}.md`);
461
501
  }
462
-
463
- if (!fs.existsSync(fullPath)) {
464
- return null;
465
- }
466
-
467
- return parseMarkdownFile(fullPath, slug);
502
+ if (!fs.existsSync(fullPath)) return null;
503
+ return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
468
504
  } catch {
469
505
  return null;
470
506
  }
@@ -473,11 +509,21 @@ export function getPageBySlug(slug: string): PostData | null {
473
509
  export function getAllPages(): PostData[] {
474
510
  const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
475
511
  return items
476
- .filter(item => item.isFile() && (item.name.endsWith('.mdx') || item.name.endsWith('.md')))
512
+ .filter(item => {
513
+ if (!item.isFile()) return false;
514
+ if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
515
+ // Exclude locale variant files (e.g. about.zh.mdx, about.en.mdx) — they are not standalone routes
516
+ const base = item.name.replace(/\.mdx?$/, '');
517
+ const parts = base.split('.');
518
+ if (parts.length > 1 && siteConfig.i18n.locales.includes(parts[parts.length - 1])) {
519
+ return false;
520
+ }
521
+ return true;
522
+ })
477
523
  .map(item => {
478
524
  const slug = item.name.replace(/\.mdx?$/, '');
479
525
  const fullPath = path.join(pagesDirectory, item.name);
480
- return parseMarkdownFile(fullPath, slug);
526
+ return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
481
527
  });
482
528
  }
483
529
 
@@ -658,6 +704,16 @@ export function getFeaturedPosts(): PostData[] {
658
704
  return allPosts.filter(post => post.featured);
659
705
  }
660
706
 
707
+ export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
708
+ const allPosts = getAllPosts(); // sorted desc by date (newest first)
709
+ const index = allPosts.findIndex(p => p.slug === slug);
710
+ if (index === -1) return { prev: null, next: null };
711
+ return {
712
+ prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
713
+ next: index > 0 ? allPosts[index - 1] : null, // newer post
714
+ };
715
+ }
716
+
661
717
  export function getFeaturedSeries(): Record<string, PostData[]> {
662
718
  const allSeries = getAllSeries();
663
719
  const featuredSeries: Record<string, PostData[]> = {};
@@ -1001,7 +1057,7 @@ export function getAllFlows(): FlowData[] {
1001
1057
  return flows
1002
1058
  .filter(flow => {
1003
1059
  if (process.env.NODE_ENV === 'production' && flow.draft) return false;
1004
- if (!siteConfig.showFuturePosts) {
1060
+ if (!siteConfig.posts?.showFuturePosts) {
1005
1061
  const flowDate = new Date(flow.date);
1006
1062
  const now = new Date();
1007
1063
  if (flowDate > now) return false;
@@ -0,0 +1,163 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { getResultType, getDateFromUrl, cleanTitle, stripMarkdown } from './search-utils';
3
+
4
+ // ─── getResultType ────────────────────────────────────────────────────────────
5
+
6
+ describe('getResultType', () => {
7
+ test('returns Flow for flow URLs', () => {
8
+ expect(getResultType('/flows/2026/01/15/')).toBe('Flow');
9
+ });
10
+
11
+ test('returns Flow for any path containing /flows/', () => {
12
+ expect(getResultType('/flows/2024/12/31/')).toBe('Flow');
13
+ });
14
+
15
+ test('returns Book for book chapter URLs', () => {
16
+ expect(getResultType('/books/my-book/chapter-1/')).toBe('Book');
17
+ });
18
+
19
+ test('returns Book for book index URLs', () => {
20
+ expect(getResultType('/books/my-book/')).toBe('Book');
21
+ });
22
+
23
+ test('returns Post for post URLs', () => {
24
+ expect(getResultType('/posts/my-post/')).toBe('Post');
25
+ });
26
+
27
+ test('returns Post for root static pages', () => {
28
+ expect(getResultType('/about/')).toBe('Post');
29
+ });
30
+
31
+ test('returns Post for paginated post pages', () => {
32
+ expect(getResultType('/posts/page/2/')).toBe('Post');
33
+ });
34
+ });
35
+
36
+ // ─── getDateFromUrl ───────────────────────────────────────────────────────────
37
+
38
+ describe('getDateFromUrl', () => {
39
+ test('extracts YYYY-MM-DD from a flow URL', () => {
40
+ expect(getDateFromUrl('/flows/2026/01/15/')).toBe('2026-01-15');
41
+ });
42
+
43
+ test('pads single-digit month and day correctly', () => {
44
+ expect(getDateFromUrl('/flows/2024/03/07/')).toBe('2024-03-07');
45
+ });
46
+
47
+ test('returns empty string for post URLs', () => {
48
+ expect(getDateFromUrl('/posts/my-post/')).toBe('');
49
+ });
50
+
51
+ test('returns empty string for book URLs', () => {
52
+ expect(getDateFromUrl('/books/my-book/chapter/')).toBe('');
53
+ });
54
+
55
+ test('returns empty string when flow URL has no trailing slash', () => {
56
+ // Pagefind always returns URLs with trailing slash; guard against edge case
57
+ expect(getDateFromUrl('/flows/2024/12/31')).toBe('');
58
+ });
59
+
60
+ test('returns empty string for the root path', () => {
61
+ expect(getDateFromUrl('/')).toBe('');
62
+ });
63
+ });
64
+
65
+ // ─── cleanTitle ───────────────────────────────────────────────────────────────
66
+
67
+ describe('cleanTitle', () => {
68
+ test('strips " | Site Name" suffix', () => {
69
+ expect(cleanTitle('My Post | My Site')).toBe('My Post');
70
+ });
71
+
72
+ test('returns the title unchanged when there is no suffix', () => {
73
+ expect(cleanTitle('My Post')).toBe('My Post');
74
+ });
75
+
76
+ test('strips only the last " | " occurrence (keeps earlier pipes)', () => {
77
+ expect(cleanTitle('Part A | Part B | Site')).toBe('Part A | Part B');
78
+ });
79
+
80
+ test('handles an empty string', () => {
81
+ expect(cleanTitle('')).toBe('');
82
+ });
83
+
84
+ test('handles a title that is only a site suffix', () => {
85
+ expect(cleanTitle(' | Site')).toBe('');
86
+ });
87
+ });
88
+
89
+ // ─── stripMarkdown ────────────────────────────────────────────────────────────
90
+
91
+ describe('stripMarkdown', () => {
92
+ test('strips fenced code blocks', () => {
93
+ const input = 'Hello\n```js\nconst x = 1;\n```\nWorld';
94
+ expect(stripMarkdown(input)).toBe('Hello World');
95
+ });
96
+
97
+ test('strips inline code', () => {
98
+ expect(stripMarkdown('Use `npm install` to install')).toBe('Use to install');
99
+ });
100
+
101
+ test('strips image syntax', () => {
102
+ expect(stripMarkdown('![alt text](image.png) after')).toBe('after');
103
+ });
104
+
105
+ test('converts links to their visible text', () => {
106
+ expect(stripMarkdown('See [the docs](https://example.com) here')).toBe('See the docs here');
107
+ });
108
+
109
+ test('strips HTML and JSX tags', () => {
110
+ expect(stripMarkdown('<div class="foo">content</div>')).toBe('content');
111
+ });
112
+
113
+ test('strips ATX heading markers (# through ######)', () => {
114
+ expect(stripMarkdown('## Introduction')).toBe('Introduction');
115
+ expect(stripMarkdown('###### Deep heading')).toBe('Deep heading');
116
+ });
117
+
118
+ test('strips bold and italic with asterisks', () => {
119
+ expect(stripMarkdown('**bold** and *italic* text')).toBe('bold and italic text');
120
+ });
121
+
122
+ test('strips bold and italic with underscores', () => {
123
+ expect(stripMarkdown('__bold__ and _italic_ text')).toBe('bold and italic text');
124
+ });
125
+
126
+ test('strips GFM strikethrough markers', () => {
127
+ expect(stripMarkdown('~~deleted~~ and ~~removed~~')).toBe('deleted and removed');
128
+ });
129
+
130
+ test('strips unordered list markers', () => {
131
+ expect(stripMarkdown('- item one\n- item two')).toBe('item one item two');
132
+ });
133
+
134
+ test('strips blockquote markers', () => {
135
+ expect(stripMarkdown('> quoted text')).toBe('quoted text');
136
+ });
137
+
138
+ test('strips ordered list markers', () => {
139
+ expect(stripMarkdown('1. First\n2. Second')).toBe('First Second');
140
+ });
141
+
142
+ test('normalizes multiple spaces and newlines to a single space', () => {
143
+ expect(stripMarkdown('word1 word2\n\nword3')).toBe('word1 word2 word3');
144
+ });
145
+
146
+ test('trims leading and trailing whitespace', () => {
147
+ expect(stripMarkdown(' hello world ')).toBe('hello world');
148
+ });
149
+
150
+ test('caps output at 2000 characters', () => {
151
+ const long = 'a'.repeat(3000);
152
+ expect(stripMarkdown(long).length).toBe(2000);
153
+ });
154
+
155
+ test('returns empty string for empty input', () => {
156
+ expect(stripMarkdown('')).toBe('');
157
+ });
158
+
159
+ test('handles mixed markdown in one passage', () => {
160
+ const input = '## Title\n\n**Bold** and [link](http://example.com).\n\n- item\n\n> quote';
161
+ expect(stripMarkdown(input)).toBe('Title Bold and link. item quote');
162
+ });
163
+ });
@@ -0,0 +1,39 @@
1
+ export type ContentType = 'All' | 'Post' | 'Flow' | 'Book';
2
+
3
+ /** Derive content type from a Pagefind result URL. */
4
+ export function getResultType(url: string): Exclude<ContentType, 'All'> {
5
+ if (url.includes('/flows/')) return 'Flow';
6
+ if (url.includes('/books/')) return 'Book';
7
+ return 'Post';
8
+ }
9
+
10
+ /** Extract YYYY-MM-DD from a flow URL like /flows/2026/01/15/ */
11
+ export function getDateFromUrl(url: string): string {
12
+ const m = url.match(/\/flows\/(\d{4})\/(\d{2})\/(\d{2})\//);
13
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : '';
14
+ }
15
+
16
+ /** Strip the " | Site Name" suffix that Pagefind picks up from <title>. */
17
+ export function cleanTitle(raw: string): string {
18
+ const i = raw.lastIndexOf(' | ');
19
+ return i >= 0 ? raw.slice(0, i) : raw;
20
+ }
21
+
22
+ /** Strip markdown/MDX syntax to plain text for full-content indexing. */
23
+ export function stripMarkdown(text: string): string {
24
+ return text
25
+ .replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
26
+ .replace(/`[^`\n]+`/g, ' ') // inline code
27
+ .replace(/!\[.*?\]\(.*?\)/g, '') // images
28
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links → text
29
+ .replace(/<[^>]+>/g, ' ') // HTML/JSX/MDX tags
30
+ .replace(/^#{1,6}\s+/gm, '') // heading markers
31
+ .replace(/\*{1,2}([^*\n]+)\*{1,2}/g, '$1') // bold/italic (*)
32
+ .replace(/_{1,2}([^_\n]+)_{1,2}/g, '$1') // bold/italic (_)
33
+ .replace(/~~([^~\n]+)~~/g, '$1') // strikethrough
34
+ .replace(/^\s*[-*+>]\s+/gm, '') // lists + blockquotes
35
+ .replace(/^\s*\d+\.\s+/gm, '') // ordered lists
36
+ .replace(/\s+/g, ' ') // normalize whitespace
37
+ .trim()
38
+ .slice(0, 2000); // cap for index size
39
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Type declarations for Pagefind's JS API.
3
+ * Pagefind is generated at build time (`pagefind --site out`) and
4
+ * loaded at runtime via a dynamic import. The module does not exist
5
+ * at compile time, so we declare it manually here.
6
+ *
7
+ * Docs: https://pagefind.app/docs/api/
8
+ */
9
+ declare module '/pagefind/pagefind.js' {
10
+ export interface PagefindSearchFragment {
11
+ /** URL of the matching page, e.g. "/posts/my-post/" */
12
+ url: string;
13
+ /** Excerpt with matched terms wrapped in <mark> tags */
14
+ excerpt: string;
15
+ /** Metadata extracted from the page */
16
+ meta: {
17
+ title?: string;
18
+ image?: string;
19
+ /** Any custom data-pagefind-meta keys defined in the site */
20
+ [key: string]: string | undefined;
21
+ };
22
+ word_count: number;
23
+ }
24
+
25
+ export interface PagefindSearchResult {
26
+ /** Unique result ID, e.g. "en_6fceec9" */
27
+ id: string;
28
+ /** Load the full result data (lazy, returns only the matching page chunk) */
29
+ data: () => Promise<PagefindSearchFragment>;
30
+ }
31
+
32
+ export interface PagefindSearchResponse {
33
+ results: PagefindSearchResult[];
34
+ unfilteredResultCount: number;
35
+ }
36
+
37
+ /** Initialise Pagefind — must be called before search() */
38
+ export function init(): Promise<void>;
39
+
40
+ /** Run a search and return lazy result handles */
41
+ export function search(query: string): Promise<PagefindSearchResponse>;
42
+ }
@@ -1,158 +0,0 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useCallback } from 'react';
4
- import { Heading } from '@/lib/markdown';
5
- import { useLanguage } from '@/components/LanguageProvider';
6
-
7
- export default function TableOfContents({ headings }: { headings: Heading[] }) {
8
- const { t } = useLanguage();
9
- const [activeId, setActiveId] = useState<string>('');
10
- const [readProgress, setReadProgress] = useState(0);
11
-
12
- // Track scroll position and active heading
13
- const handleScroll = useCallback(() => {
14
- // Calculate read progress
15
- const scrollTop = window.scrollY;
16
- const docHeight = document.documentElement.scrollHeight - window.innerHeight;
17
- const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
18
- setReadProgress(Math.min(100, Math.max(0, progress)));
19
-
20
- // Find active heading
21
- const headingElements = headings
22
- .map(h => document.getElementById(h.id))
23
- .filter(Boolean) as HTMLElement[];
24
-
25
- if (headingElements.length === 0) return;
26
-
27
- // Find the heading that's currently in view
28
- const scrollPosition = scrollTop + 100; // Offset for navbar
29
-
30
- let currentHeading = headingElements[0];
31
- for (const heading of headingElements) {
32
- if (heading.offsetTop <= scrollPosition) {
33
- currentHeading = heading;
34
- } else {
35
- break;
36
- }
37
- }
38
-
39
- if (currentHeading) {
40
- setActiveId(currentHeading.id);
41
- }
42
- }, [headings]);
43
-
44
- useEffect(() => {
45
- // Initial check on mount via animation frame to avoid cascading render error
46
- const rafId = requestAnimationFrame(handleScroll);
47
-
48
- window.addEventListener('scroll', handleScroll, { passive: true });
49
- return () => {
50
- cancelAnimationFrame(rafId);
51
- window.removeEventListener('scroll', handleScroll);
52
- };
53
- }, [handleScroll]);
54
-
55
- // Smooth scroll to heading
56
- const scrollToHeading = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
57
- e.preventDefault();
58
- const element = document.getElementById(id);
59
- if (element) {
60
- const offset = 80; // Navbar height
61
- const elementPosition = element.getBoundingClientRect().top + window.scrollY;
62
- window.scrollTo({
63
- top: elementPosition - offset,
64
- behavior: 'smooth'
65
- });
66
- // Update URL without scrolling
67
- history.pushState(null, '', `#${id}`);
68
- }
69
- };
70
-
71
- if (headings.length === 0) return null;
72
-
73
- // Find active index for progress calculation
74
- const activeIndex = headings.findIndex(h => h.id === activeId);
75
-
76
- return (
77
- <nav
78
- className="hidden lg:block sticky top-28 self-start w-56 pl-6 max-h-[calc(100vh-8rem)] overflow-y-auto scrollbar-hide"
79
- aria-label="Table of contents"
80
- >
81
- {/* Header with progress */}
82
- <div className="flex items-center justify-between mb-4 pb-3 border-b border-muted/10">
83
- <h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
84
- {t('on_this_page')}
85
- </h2>
86
- <span className="text-[10px] font-mono text-muted/60">
87
- {Math.round(readProgress)}%
88
- </span>
89
- </div>
90
-
91
- {/* Progress bar */}
92
- <div className="h-0.5 bg-muted/10 rounded-full overflow-hidden mb-5">
93
- <div
94
- className="h-full bg-accent/50 rounded-full transition-all duration-150"
95
- style={{ width: `${readProgress}%` }}
96
- />
97
- </div>
98
-
99
- {/* Headings list */}
100
- <ul className="space-y-1 relative">
101
- {/* Active indicator line */}
102
- <div className="absolute left-0 top-0 bottom-0 w-px bg-muted/10" />
103
-
104
- {headings.map((heading, index) => {
105
- const isActive = heading.id === activeId;
106
- const isPast = activeIndex > -1 && index < activeIndex;
107
- const isH3 = heading.level === 3;
108
-
109
- return (
110
- <li
111
- key={heading.id}
112
- className={`relative ${isH3 ? 'pl-4' : ''}`}
113
- >
114
- {/* Active indicator */}
115
- {isActive && (
116
- <div
117
- className="absolute left-0 w-0.5 bg-accent rounded-full transition-all duration-200"
118
- style={{
119
- top: '4px',
120
- height: 'calc(100% - 8px)'
121
- }}
122
- />
123
- )}
124
-
125
- <a
126
- href={`#${heading.id}`}
127
- onClick={(e) => scrollToHeading(e, heading.id)}
128
- className={`block py-1.5 pl-4 text-sm leading-snug transition-all duration-200 ${
129
- isActive
130
- ? 'text-accent font-medium'
131
- : isPast
132
- ? 'text-foreground/60 hover:text-foreground'
133
- : 'text-muted/70 hover:text-foreground'
134
- }`}
135
- aria-current={isActive ? 'location' : undefined}
136
- >
137
- {heading.text}
138
- </a>
139
- </li>
140
- );
141
- })}
142
- </ul>
143
-
144
- {/* Back to top */}
145
- {readProgress > 20 && (
146
- <button
147
- onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
148
- className="mt-6 pt-4 border-t border-muted/10 w-full text-left text-xs text-muted hover:text-accent transition-colors flex items-center gap-1.5"
149
- >
150
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
151
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
152
- </svg>
153
- {t('back_to_top')}
154
- </button>
155
- )}
156
- </nav>
157
- );
158
- }