@hutusi/amytis 1.16.0 → 1.17.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 (51) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +16 -0
  4. package/CLAUDE.md +10 -11
  5. package/docs/ARCHITECTURE.md +81 -0
  6. package/docs/DIGITAL_GARDEN.md +1 -1
  7. package/docs/guides/importing-vuepress-books.md +95 -36
  8. package/package.json +1 -1
  9. package/scripts/sync-vuepress-book.ts +277 -66
  10. package/site.config.example.ts +3 -3
  11. package/site.config.ts +3 -3
  12. package/src/app/[slug]/layout.tsx +30 -0
  13. package/src/app/books/[slug]/layout.tsx +24 -0
  14. package/src/app/books/[slug]/page.tsx +18 -2
  15. package/src/app/globals.css +67 -0
  16. package/src/app/page.tsx +6 -0
  17. package/src/app/posts/layout.tsx +20 -0
  18. package/src/app/series/[slug]/page.tsx +33 -9
  19. package/src/components/BookReadingShell.tsx +145 -0
  20. package/src/components/BookSidebar.tsx +0 -0
  21. package/src/components/CuratedSeriesSection.tsx +28 -10
  22. package/src/components/FeaturedStoriesSection.tsx +41 -20
  23. package/src/components/Footer.tsx +1 -1
  24. package/src/components/ImmersiveReader.tsx +130 -0
  25. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  26. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  27. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  28. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  29. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  30. package/src/components/ImmersiveToggleButton.tsx +45 -0
  31. package/src/components/MarkdownRenderer.tsx +31 -0
  32. package/src/components/Navbar.tsx +3 -1
  33. package/src/components/PostReadingShell.tsx +68 -0
  34. package/src/components/ReadingProgressBar.tsx +1 -1
  35. package/src/components/SelectedBooksSection.tsx +27 -8
  36. package/src/hooks/useActiveHeading.ts +35 -13
  37. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  38. package/src/i18n/translations.ts +42 -0
  39. package/src/layouts/BookLayout.tsx +46 -89
  40. package/src/layouts/PostLayout.tsx +154 -115
  41. package/src/lib/immersive-reading-prefs.ts +104 -0
  42. package/src/lib/markdown.ts +18 -11
  43. package/src/lib/scroll-utils.ts +44 -6
  44. package/src/lib/shuffle.ts +15 -1
  45. package/src/lib/sort.ts +15 -0
  46. package/src/lib/urls.ts +5 -0
  47. package/tests/integration/book-index-cta.test.ts +87 -0
  48. package/tests/integration/series-index-cta.test.ts +88 -0
  49. package/tests/integration/sync-vuepress-book.test.ts +205 -2
  50. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  51. package/vercel.json +7 -0
@@ -1,12 +1,7 @@
1
1
  import path from 'path';
2
2
  import { BookData, BookChapterData, getBookDirPath } from '@/lib/markdown';
3
3
  import MarkdownRenderer from '@/components/MarkdownRenderer';
4
- import BookSidebar from '@/components/BookSidebar';
5
- import BookMobileNav from '@/components/BookMobileNav';
6
- import PrevNextNav from '@/components/PrevNextNav';
7
- import ReadingProgressBar from '@/components/ReadingProgressBar';
8
- import Comments from '@/components/Comments';
9
- import { t } from '@/lib/i18n';
4
+ import BookReadingShell from '@/components/BookReadingShell';
10
5
  import { getBookChapterUrl } from '@/lib/urls';
11
6
  import { siteConfig } from '../../site.config';
12
7
  import { resolveCommentable } from '@/lib/comments';
@@ -33,89 +28,51 @@ export default function BookLayout({ book, chapter }: BookLayoutProps) {
33
28
  imageSlug = parentDir === '.' ? `books/${book.slug}` : `books/${book.slug}/${parentDir}`;
34
29
  }
35
30
 
36
- return (
37
- <div className="layout-container lg:max-w-7xl">
38
- <ReadingProgressBar />
39
- <div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
40
- {/* Left: Sidebar */}
41
- <BookSidebar
42
- bookSlug={book.slug}
43
- bookTitle={book.title}
44
- toc={book.toc}
45
- chapters={book.chapters}
46
- currentChapter={chapter.slug}
47
- headings={chapter.headings}
48
- />
49
-
50
- {/* Main content */}
51
- <article className="min-w-0 w-full max-w-3xl overflow-x-hidden">
52
- {/* Mobile nav */}
53
- <div className="lg:hidden mb-8">
54
- <BookMobileNav
55
- bookSlug={book.slug}
56
- bookTitle={book.title}
57
- toc={book.toc}
58
- chapters={book.chapters}
59
- currentChapter={chapter.slug}
60
- />
61
- </div>
62
-
63
- {/* Chapter header */}
64
- <header className="mb-12 pb-8 border-b border-muted/10">
65
- <div className="flex items-center gap-3 text-xs font-sans text-muted mb-4">
66
- <span className="uppercase tracking-widest font-semibold text-accent">
67
- {t('chapter')}
68
- </span>
69
- <span className="w-1 h-1 rounded-full bg-muted/30" />
70
- <span className="font-mono">
71
- {chapter.wordCount.toLocaleString()} {t('words')}
72
- </span>
73
- <span className="w-1 h-1 rounded-full bg-muted/30" />
74
- <span className="font-mono text-muted/70">{chapter.readingMinutes} {t('reading_time')}</span>
75
- </div>
76
-
77
- <h1 className="text-3xl md:text-4xl font-serif font-bold text-heading leading-tight mb-4">
78
- {chapter.title}
79
- </h1>
31
+ const prev = chapter.prevChapter
32
+ ? { href: getBookChapterUrl(book.slug, chapter.prevChapter.id), title: chapter.prevChapter.title }
33
+ : null;
34
+ const next = chapter.nextChapter
35
+ ? { href: getBookChapterUrl(book.slug, chapter.nextChapter.id), title: chapter.nextChapter.title }
36
+ : null;
37
+ const comments = resolveCommentable(chapter.commentable, 'bookChapters')
38
+ ? {
39
+ slug: `books/${book.slug}/${chapter.slug}`,
40
+ postUrl: `${siteConfig.baseUrl.replace(/\/+$/, '')}${getBookChapterUrl(book.slug, chapter.slug)}`,
41
+ }
42
+ : null;
80
43
 
81
- {book.showChapterExcerpt && chapter.excerpt && (
82
- <p className="text-lg text-muted font-serif italic leading-relaxed">
83
- {chapter.excerpt}
84
- </p>
85
- )}
86
- </header>
87
-
88
- {/* Content */}
89
- <MarkdownRenderer
90
- content={chapter.content}
91
- latex={chapter.latex}
92
- slug={imageSlug}
93
- bookContext={{
94
- bookSlug: book.slug,
95
- bookDir,
96
- chapterSourcePath: chapter.sourcePath,
97
- validChapterIds,
98
- }}
99
- />
100
-
101
- {/* Comments */}
102
- {resolveCommentable(chapter.commentable, 'bookChapters') && (
103
- <Comments
104
- slug={`books/${book.slug}/${chapter.slug}`}
105
- postUrl={`${siteConfig.baseUrl.replace(/\/+$/, '')}${getBookChapterUrl(book.slug, chapter.slug)}`}
106
- />
107
- )}
108
-
109
- {/* Prev/Next navigation */}
110
- <div className="mt-16 pt-8 border-t border-muted/10">
111
- <PrevNextNav
112
- prev={chapter.prevChapter ? { href: getBookChapterUrl(book.slug, chapter.prevChapter.id), title: chapter.prevChapter.title } : null}
113
- next={chapter.nextChapter ? { href: getBookChapterUrl(book.slug, chapter.nextChapter.id), title: chapter.nextChapter.title } : null}
114
- size="lg"
115
- />
116
- </div>
117
- </article>
118
- </div>
119
- </div>
44
+ return (
45
+ <BookReadingShell
46
+ book={{
47
+ slug: book.slug,
48
+ title: book.title,
49
+ toc: book.toc,
50
+ chapters: book.chapters,
51
+ showChapterExcerpt: book.showChapterExcerpt,
52
+ }}
53
+ chapter={{
54
+ slug: chapter.slug,
55
+ title: chapter.title,
56
+ wordCount: chapter.wordCount,
57
+ readingMinutes: chapter.readingMinutes,
58
+ excerpt: chapter.excerpt,
59
+ headings: chapter.headings,
60
+ }}
61
+ prev={prev}
62
+ next={next}
63
+ comments={comments}
64
+ >
65
+ <MarkdownRenderer
66
+ content={chapter.content}
67
+ latex={chapter.latex}
68
+ slug={imageSlug}
69
+ bookContext={{
70
+ bookSlug: book.slug,
71
+ bookDir,
72
+ chapterSourcePath: chapter.sourcePath,
73
+ validChapterIds,
74
+ }}
75
+ />
76
+ </BookReadingShell>
120
77
  );
121
78
  }
@@ -15,6 +15,8 @@ import ReadingProgressBar from '@/components/ReadingProgressBar';
15
15
  import PostNavigation from '@/components/PostNavigation';
16
16
  import AuthorCard from '@/components/AuthorCard';
17
17
  import ShareBar from '@/components/ShareBar';
18
+ import ImmersiveToggleButton from '@/components/ImmersiveToggleButton';
19
+ import PostReadingShell from '@/components/PostReadingShell';
18
20
  import { siteConfig } from '../../site.config';
19
21
  import { t } from '@/lib/i18n';
20
22
  import { getPostUrl, getStaticPageUrl } from '@/lib/urls';
@@ -46,142 +48,179 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
46
48
  ? <RstRenderer content={post.content} html={post.renderedHtml} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />
47
49
  : <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />;
48
50
 
49
- return (
50
- <div className="layout-container">
51
- <ReadingProgressBar />
52
- <div className={showSidebar
53
- ? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
54
- : 'max-w-6xl mx-auto'
55
- }>
56
- {/* Left sidebar: series nav + page TOC */}
57
- {showSidebar && (
58
- <Suspense fallback={null}>
59
- <PostSidebar
60
- seriesSlug={hasSeries ? post.series : undefined}
61
- seriesTitle={hasSeries ? (seriesTitle || post.series) : undefined}
62
- posts={hasSeries ? seriesPosts : undefined}
63
- collectionContexts={collectionContexts}
64
- currentSlug={post.slug}
65
- headings={showToc ? post.headings : []}
66
- shareUrl={postUrl}
67
- shareTitle={post.title}
68
- />
69
- </Suspense>
51
+ // The article <header> is identical in both the normal and the immersive
52
+ // article subtrees — extracting it as a variable lets us reference it in
53
+ // both places (only one of the two trees ever mounts, so it renders once).
54
+ // Includes the immersive toggle when the post is in a series.
55
+ const articleHeader = (
56
+ <header className="mb-16 border-b border-muted/10 pb-8">
57
+ {post.draft && (
58
+ <div className="mb-4">
59
+ <span className="text-xs font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded tracking-widest inline-block">
60
+ DRAFT
61
+ </span>
62
+ </div>
63
+ )}
64
+ <div className="flex items-center gap-3 text-xs font-sans text-muted mb-6">
65
+ <span className="uppercase tracking-widest font-semibold text-accent">
66
+ {post.category}
67
+ </span>
68
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
69
+ <time className="font-mono" data-pagefind-meta="date[content]">{post.date}</time>
70
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
71
+ <span className="font-mono">
72
+ {post.wordCount.toLocaleString()} {t('words')}
73
+ </span>
74
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
75
+ <span className="font-mono text-muted/70">{post.readingMinutes} {t('reading_time')}</span>
76
+ {hasSeries && (
77
+ <span className="ml-auto">
78
+ <ImmersiveToggleButton />
79
+ </span>
70
80
  )}
81
+ </div>
71
82
 
72
- <article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
73
- <header className="mb-16 border-b border-muted/10 pb-8">
74
- {post.draft && (
75
- <div className="mb-4">
76
- <span className="text-xs font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded tracking-widest inline-block">
77
- DRAFT
78
- </span>
79
- </div>
80
- )}
81
- <div className="flex items-center gap-3 text-xs font-sans text-muted mb-6">
82
- <span className="uppercase tracking-widest font-semibold text-accent">
83
- {post.category}
84
- </span>
85
- <span className="w-1 h-1 rounded-full bg-muted/30" />
86
- <time className="font-mono" data-pagefind-meta="date[content]">{post.date}</time>
87
- <span className="w-1 h-1 rounded-full bg-muted/30" />
88
- <span className="font-mono">
89
- {post.wordCount.toLocaleString()} {t('words')}
83
+ <h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-4">
84
+ {post.title}
85
+ </h1>
86
+
87
+ {post.subtitle && (
88
+ <p className="text-xl md:text-2xl font-serif italic text-muted leading-snug mb-6">
89
+ {post.subtitle}
90
+ </p>
91
+ )}
92
+
93
+ {siteConfig.posts?.authors?.showInHeader !== false && post.authors.length > 0 && (
94
+ <div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
95
+ <span>{t('written_by')}</span>
96
+ <div className="flex items-center gap-1">
97
+ {post.authors.map((author, index) => (
98
+ <span key={author} className="flex items-center">
99
+ <Link
100
+ href={`/authors/${getAuthorSlug(author)}`}
101
+ className="text-foreground hover:text-accent no-underline transition-colors duration-200"
102
+ >
103
+ {author}
104
+ </Link>
105
+ {index < post.authors.length - 1 && <span className="mr-1">,</span>}
90
106
  </span>
91
- <span className="w-1 h-1 rounded-full bg-muted/30" />
92
- <span className="font-mono text-muted/70">{post.readingMinutes} {t('reading_time')}</span>
93
- </div>
94
-
95
- <h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-4">
96
- {post.title}
97
- </h1>
98
-
99
- {post.subtitle && (
100
- <p className="text-xl md:text-2xl font-serif italic text-muted leading-snug mb-6">
101
- {post.subtitle}
102
- </p>
103
- )}
107
+ ))}
108
+ </div>
109
+ </div>
110
+ )}
111
+
112
+ {post.excerpt && (
113
+ <p className="text-xl text-foreground font-serif italic leading-relaxed mb-8">
114
+ {post.excerpt}
115
+ </p>
116
+ )}
117
+
118
+ {post.tags && post.tags.length > 0 && (
119
+ <div className="flex flex-wrap gap-2">
120
+ {post.tags.map((tag) => (
121
+ <Tag key={tag} tag={tag} variant="default" />
122
+ ))}
123
+ </div>
124
+ )}
125
+ </header>
126
+ );
127
+
128
+ // Slim subtree for the immersive overlay: header + body + prev/next nav. We
129
+ // deliberately skip AuthorCard / ShareBar / Comments / RelatedPosts here —
130
+ // the reader is focused on long-form reading; chrome lives in normal mode.
131
+ const overlayArticle = (
132
+ <>
133
+ {articleHeader}
134
+ {bodyRenderer}
135
+ <div className="mt-16 pt-8 border-t border-muted/10">
136
+ <Suspense fallback={null}>
137
+ <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
138
+ </Suspense>
139
+ </div>
140
+ </>
141
+ );
142
+
143
+ return (
144
+ <PostReadingShell
145
+ post={post}
146
+ seriesSlug={hasSeries ? post.series : undefined}
147
+ seriesTitle={hasSeries ? (seriesTitle || post.series) : undefined}
148
+ seriesPosts={hasSeries ? seriesPosts : undefined}
149
+ collectionContexts={collectionContexts}
150
+ overlayArticle={overlayArticle}
151
+ >
152
+ <div className="layout-container">
153
+ <ReadingProgressBar />
154
+ <div className={showSidebar
155
+ ? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
156
+ : 'max-w-6xl mx-auto'
157
+ }>
158
+ {/* Left sidebar: series nav + page TOC */}
159
+ {showSidebar && (
160
+ <Suspense fallback={null}>
161
+ <PostSidebar
162
+ seriesSlug={hasSeries ? post.series : undefined}
163
+ seriesTitle={hasSeries ? (seriesTitle || post.series) : undefined}
164
+ posts={hasSeries ? seriesPosts : undefined}
165
+ collectionContexts={collectionContexts}
166
+ currentSlug={post.slug}
167
+ headings={showToc ? post.headings : []}
168
+ shareUrl={postUrl}
169
+ shareTitle={post.title}
170
+ />
171
+ </Suspense>
172
+ )}
104
173
 
105
- {siteConfig.posts?.authors?.showInHeader !== false && post.authors.length > 0 && (
106
- <div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
107
- <span>{t('written_by')}</span>
108
- <div className="flex items-center gap-1">
109
- {post.authors.map((author, index) => (
110
- <span key={author} className="flex items-center">
111
- <Link
112
- href={`/authors/${getAuthorSlug(author)}`}
113
- className="text-foreground hover:text-accent no-underline transition-colors duration-200"
114
- >
115
- {author}
116
- </Link>
117
- {index < post.authors.length - 1 && <span className="mr-1">,</span>}
118
- </span>
119
- ))}
120
- </div>
174
+ <article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
175
+ {articleHeader}
176
+
177
+ {(hasSeries || (collectionContexts && collectionContexts.length > 0)) && (
178
+ <div className="lg:hidden mb-12">
179
+ <Suspense fallback={null}>
180
+ <SeriesList seriesSlug={hasSeries ? post.series! : undefined} seriesTitle={hasSeries ? (seriesTitle || post.series!) : undefined} posts={hasSeries ? seriesPosts! : undefined} collectionContexts={collectionContexts} currentSlug={post.slug} />
181
+ </Suspense>
121
182
  </div>
122
183
  )}
123
184
 
124
- {post.excerpt && (
125
- <p className="text-xl text-foreground font-serif italic leading-relaxed mb-8">
126
- {post.excerpt}
127
- </p>
185
+ {bodyRenderer}
186
+
187
+ {siteConfig.posts?.authors?.showAuthorCard !== false && (
188
+ <AuthorCard authors={post.authors} />
128
189
  )}
129
190
 
130
191
  {post.tags && post.tags.length > 0 && (
131
- <div className="flex flex-wrap gap-2">
192
+ <div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
193
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mr-1">{t('tags')}</span>
132
194
  {post.tags.map((tag) => (
133
195
  <Tag key={tag} tag={tag} variant="default" />
134
196
  ))}
135
197
  </div>
136
198
  )}
137
- </header>
138
-
139
- {(hasSeries || (collectionContexts && collectionContexts.length > 0)) && (
140
- <div className="lg:hidden mb-12">
141
- <Suspense fallback={null}>
142
- <SeriesList seriesSlug={hasSeries ? post.series! : undefined} seriesTitle={hasSeries ? (seriesTitle || post.series!) : undefined} posts={hasSeries ? seriesPosts! : undefined} collectionContexts={collectionContexts} currentSlug={post.slug} />
143
- </Suspense>
144
- </div>
145
- )}
146
-
147
- {bodyRenderer}
148
199
 
149
- {siteConfig.posts?.authors?.showAuthorCard !== false && (
150
- <AuthorCard authors={post.authors} />
151
- )}
152
-
153
- {post.tags && post.tags.length > 0 && (
154
- <div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
155
- <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mr-1">{t('tags')}</span>
156
- {post.tags.map((tag) => (
157
- <Tag key={tag} tag={tag} variant="default" />
158
- ))}
159
- </div>
160
- )}
200
+ <Backlinks backlinks={backlinks ?? []} />
161
201
 
162
- <Backlinks backlinks={backlinks ?? []} />
163
-
164
- <ShareBar
165
- url={postUrl}
166
- title={post.title}
167
- className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
168
- />
202
+ <ShareBar
203
+ url={postUrl}
204
+ title={post.title}
205
+ className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
206
+ />
169
207
 
170
- {resolveCommentable(post.commentable, commentCategory) && (
171
- <Comments slug={commentSlug} postUrl={postUrl} />
172
- )}
208
+ {resolveCommentable(post.commentable, commentCategory) && (
209
+ <Comments slug={commentSlug} postUrl={postUrl} />
210
+ )}
173
211
 
174
- {post.externalLinks && post.externalLinks.length > 0 && (
175
- <ExternalLinks links={post.externalLinks} />
176
- )}
212
+ {post.externalLinks && post.externalLinks.length > 0 && (
213
+ <ExternalLinks links={post.externalLinks} />
214
+ )}
177
215
 
178
- <Suspense fallback={null}>
179
- <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
180
- </Suspense>
216
+ <Suspense fallback={null}>
217
+ <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
218
+ </Suspense>
181
219
 
182
- <RelatedPosts posts={relatedPosts || []} />
183
- </article>
220
+ <RelatedPosts posts={relatedPosts || []} />
221
+ </article>
222
+ </div>
184
223
  </div>
185
- </div>
224
+ </PostReadingShell>
186
225
  );
187
226
  }
@@ -0,0 +1,104 @@
1
+ // Storage layer for the immersive reader's persisted preferences. Lives here
2
+ // (not inside ImmersiveReadingProvider) so the defensive-parsing and write-
3
+ // silencing logic is unit-testable without rendering React or mocking
4
+ // `window.localStorage` globally — tests pass an in-memory storage object via
5
+ // the optional `storage` parameter.
6
+ //
7
+ // Public contract: STORAGE_KEY is the user-visible localStorage key. Renaming
8
+ // it invalidates every existing reader's prefs — don't.
9
+
10
+ import type {
11
+ ReadingColumnWidth,
12
+ ReadingFontSize,
13
+ ReadingTheme,
14
+ } from '@/components/ImmersiveReadingProvider';
15
+
16
+ export const STORAGE_KEY = 'amytis-reader-prefs';
17
+
18
+ const FONT_SIZE_VALUES: readonly ReadingFontSize[] = ['s', 'm', 'l', 'xl'];
19
+ const THEME_VALUES: readonly ReadingTheme[] = ['auto', 'light', 'sepia', 'dark'];
20
+ const COLUMN_WIDTH_VALUES: readonly ReadingColumnWidth[] = ['narrow', 'medium', 'wide', 'full'];
21
+
22
+ export interface StoredPrefs {
23
+ fontSize: ReadingFontSize;
24
+ readingTheme: ReadingTheme;
25
+ columnWidth: ReadingColumnWidth;
26
+ sidebarOpen: boolean;
27
+ }
28
+
29
+ export const DEFAULT_PREFS: StoredPrefs = {
30
+ fontSize: 'm',
31
+ readingTheme: 'auto',
32
+ columnWidth: 'wide',
33
+ sidebarOpen: true,
34
+ };
35
+
36
+ /** Resolve the default storage lazily — `globalThis.localStorage` is undefined
37
+ * during SSR and accessing it at module top-level would crash imports. The
38
+ * try/catch is also load-bearing: in some privacy-restricted browser modes
39
+ * (Safari "block all cookies", Firefox with site data disabled) the
40
+ * `localStorage` getter itself throws a SecurityError instead of returning
41
+ * the object — without the catch, that would escape into readStoredPrefs /
42
+ * writeStoredPrefs and crash the reader. */
43
+ function defaultReadStorage(): Pick<Storage, 'getItem'> | null {
44
+ if (typeof globalThis === 'undefined') return null;
45
+ try {
46
+ const ls = (globalThis as { localStorage?: Storage }).localStorage;
47
+ return ls ?? null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function defaultWriteStorage(): Pick<Storage, 'setItem'> | null {
54
+ if (typeof globalThis === 'undefined') return null;
55
+ try {
56
+ const ls = (globalThis as { localStorage?: Storage }).localStorage;
57
+ return ls ?? null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** Read + validate prefs from storage. Per-key defensive: if any key is
64
+ * stale / unknown / wrong-typed (schema drift, manual edits), fall back to
65
+ * its default rather than discarding the whole blob. */
66
+ export function readStoredPrefs(storage?: Pick<Storage, 'getItem'>): StoredPrefs {
67
+ const ls = storage ?? defaultReadStorage();
68
+ if (!ls) return DEFAULT_PREFS;
69
+ try {
70
+ const raw = ls.getItem(STORAGE_KEY);
71
+ if (!raw) return DEFAULT_PREFS;
72
+ const parsed: unknown = JSON.parse(raw);
73
+ if (!parsed || typeof parsed !== 'object') return DEFAULT_PREFS;
74
+ const obj = parsed as Record<string, unknown>;
75
+ return {
76
+ fontSize: (FONT_SIZE_VALUES as readonly string[]).includes(obj.fontSize as string)
77
+ ? (obj.fontSize as ReadingFontSize)
78
+ : DEFAULT_PREFS.fontSize,
79
+ readingTheme: (THEME_VALUES as readonly string[]).includes(obj.readingTheme as string)
80
+ ? (obj.readingTheme as ReadingTheme)
81
+ : DEFAULT_PREFS.readingTheme,
82
+ columnWidth: (COLUMN_WIDTH_VALUES as readonly string[]).includes(obj.columnWidth as string)
83
+ ? (obj.columnWidth as ReadingColumnWidth)
84
+ : DEFAULT_PREFS.columnWidth,
85
+ sidebarOpen:
86
+ typeof obj.sidebarOpen === 'boolean' ? obj.sidebarOpen : DEFAULT_PREFS.sidebarOpen,
87
+ };
88
+ } catch {
89
+ return DEFAULT_PREFS;
90
+ }
91
+ }
92
+
93
+ export function writeStoredPrefs(
94
+ prefs: StoredPrefs,
95
+ storage?: Pick<Storage, 'setItem'>,
96
+ ): void {
97
+ const ls = storage ?? defaultWriteStorage();
98
+ if (!ls) return;
99
+ try {
100
+ ls.setItem(STORAGE_KEY, JSON.stringify(prefs));
101
+ } catch {
102
+ /* private browsing / quota exceeded — ignore */
103
+ }
104
+ }
@@ -5,6 +5,7 @@ import { siteConfig } from '../../site.config';
5
5
  import GithubSlugger from 'github-slugger';
6
6
  import { z } from 'zod';
7
7
  import { getPostUrl } from './urls';
8
+ import { byDateAsc, byDateDesc } from './sort';
8
9
  import { parseRstDocument, RstParseError } from './rst';
9
10
  import { renderRstFile, renderRstFilesBatch, type RenderedRstDocument } from './rst-renderer';
10
11
 
@@ -970,7 +971,7 @@ export function getAllPosts(): PostData[] {
970
971
  }
971
972
  return true;
972
973
  })
973
- .sort((a, b) => (a.date < b.date ? 1 : -1));
974
+ .sort(byDateDesc);
974
975
  postsCache.set(cacheKey, result);
975
976
  return result;
976
977
  }
@@ -1249,9 +1250,9 @@ export function getSeriesPosts(seriesName: string): PostData[] {
1249
1250
  // Default Sort: date-desc (Newest first)
1250
1251
  const sortOrder = seriesData?.sort || 'date-desc';
1251
1252
  if (sortOrder === 'date-asc') {
1252
- posts.sort((a, b) => (a.date > b.date ? 1 : -1));
1253
+ posts.sort(byDateAsc);
1253
1254
  } else {
1254
- posts.sort((a, b) => (a.date < b.date ? 1 : -1));
1255
+ posts.sort(byDateDesc);
1255
1256
  }
1256
1257
  }
1257
1258
 
@@ -1292,7 +1293,7 @@ export function getAllSeries(): Record<string, PostData[]> {
1292
1293
  return; // Skip draft series in production
1293
1294
  }
1294
1295
  series[slug] = seriesData?.type === 'collection'
1295
- ? getCollectionPosts(slug).slice().sort((a, b) => (a.date < b.date ? 1 : -1))
1296
+ ? getCollectionPosts(slug).slice().sort(byDateDesc)
1296
1297
  : getSeriesPosts(slug);
1297
1298
  });
1298
1299
 
@@ -1645,9 +1646,11 @@ export function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1645
1646
 
1646
1647
  /**
1647
1648
  * Resolves a chapter id (possibly nested with `/`) to a markdown file on disk.
1648
- * Returns `{ path, isFolder }` if a file exists in one of the four supported forms,
1649
- * or `null` if the id has no match. Guards against `..`-style path escapes — any
1650
- * id that resolves outside `bookDir` returns null.
1649
+ * Returns `{ path, isFolder }` if a file exists in one of the six supported
1650
+ * forms (`<id>.mdx`, `<id>.md`, `<id>/index.mdx`, `<id>/index.md`,
1651
+ * `<id>/README.mdx`, `<id>/README.md`), or `null` if the id has no match.
1652
+ * Guards against `..`-style path escapes — any id that resolves outside
1653
+ * `bookDir` returns null.
1651
1654
  */
1652
1655
  function resolveChapterFilePath(
1653
1656
  bookDir: string,
@@ -1665,10 +1668,14 @@ function resolveChapterFilePath(
1665
1668
  const chMd = `${candidate}.md`;
1666
1669
  const chFolderMdx = path.join(candidate, 'index.mdx');
1667
1670
  const chFolderMd = path.join(candidate, 'index.md');
1671
+ const chFolderReadmeMdx = path.join(candidate, 'README.mdx');
1672
+ const chFolderReadmeMd = path.join(candidate, 'README.md');
1668
1673
  if (fs.existsSync(chMdx)) return { path: chMdx, isFolder: false };
1669
1674
  if (fs.existsSync(chMd)) return { path: chMd, isFolder: false };
1670
1675
  if (fs.existsSync(chFolderMdx)) return { path: chFolderMdx, isFolder: true };
1671
1676
  if (fs.existsSync(chFolderMd)) return { path: chFolderMd, isFolder: true };
1677
+ if (fs.existsSync(chFolderReadmeMdx)) return { path: chFolderReadmeMdx, isFolder: true };
1678
+ if (fs.existsSync(chFolderReadmeMd)) return { path: chFolderReadmeMd, isFolder: true };
1672
1679
  return null;
1673
1680
  }
1674
1681
 
@@ -1707,7 +1714,7 @@ export function getBookData(slug: string): BookData | null {
1707
1714
  throw new Error(
1708
1715
  `[amytis] Book "${slug}" references chapter${missing.length === 1 ? '' : 's'} ` +
1709
1716
  `with no matching file on disk: ${missing.map(id => `"${id}"`).join(', ')}. ` +
1710
- `Expected one of <bookDir>/<id>.{md,mdx} or <bookDir>/<id>/index.{md,mdx}.`
1717
+ `Expected one of <bookDir>/<id>.{md,mdx}, <bookDir>/<id>/index.{md,mdx}, or <bookDir>/<id>/README.{md,mdx}.`
1711
1718
  );
1712
1719
  }
1713
1720
 
@@ -1821,7 +1828,7 @@ export function getAllBooks(): BookData[] {
1821
1828
  books.push(book);
1822
1829
  }
1823
1830
 
1824
- return books.sort((a, b) => (a.date < b.date ? 1 : -1));
1831
+ return books.sort(byDateDesc);
1825
1832
  }
1826
1833
 
1827
1834
  export function getFeaturedBooks(): BookData[] {
@@ -1940,7 +1947,7 @@ export function getAllFlows(): FlowData[] {
1940
1947
  }
1941
1948
  return true;
1942
1949
  })
1943
- .sort((a, b) => (a.date < b.date ? 1 : -1));
1950
+ .sort(byDateDesc);
1944
1951
  }
1945
1952
 
1946
1953
  export function getFlowBySlug(slug: string): FlowData | null {
@@ -2091,7 +2098,7 @@ export function getAllNotes(): NoteData[] {
2091
2098
 
2092
2099
  _allNotes = notes
2093
2100
  .filter(note => process.env.NODE_ENV !== 'production' || !note.draft)
2094
- .sort((a, b) => (a.date < b.date ? 1 : -1));
2101
+ .sort(byDateDesc);
2095
2102
 
2096
2103
  return _allNotes;
2097
2104
  }