@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.
- package/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +10 -11
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/importing-vuepress-books.md +95 -36
- package/package.json +1 -1
- package/scripts/sync-vuepress-book.ts +277 -66
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +18 -2
- package/src/app/globals.css +67 -0
- package/src/app/page.tsx +6 -0
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/FeaturedStoriesSection.tsx +41 -20
- package/src/components/Footer.tsx +1 -1
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.tsx +31 -0
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +42 -0
- package/src/layouts/BookLayout.tsx +46 -89
- package/src/layouts/PostLayout.tsx +154 -115
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.ts +18 -11
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +5 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +205 -2
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- 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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<span className="
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
{resolveCommentable(post.commentable, commentCategory) && (
|
|
209
|
+
<Comments slug={commentSlug} postUrl={postUrl} />
|
|
210
|
+
)}
|
|
173
211
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
212
|
+
{post.externalLinks && post.externalLinks.length > 0 && (
|
|
213
|
+
<ExternalLinks links={post.externalLinks} />
|
|
214
|
+
)}
|
|
177
215
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
216
|
+
<Suspense fallback={null}>
|
|
217
|
+
<PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
|
|
218
|
+
</Suspense>
|
|
181
219
|
|
|
182
|
-
|
|
183
|
-
|
|
220
|
+
<RelatedPosts posts={relatedPosts || []} />
|
|
221
|
+
</article>
|
|
222
|
+
</div>
|
|
184
223
|
</div>
|
|
185
|
-
</
|
|
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
|
+
}
|
package/src/lib/markdown.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
1253
|
+
posts.sort(byDateAsc);
|
|
1253
1254
|
} else {
|
|
1254
|
-
posts.sort(
|
|
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(
|
|
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
|
|
1649
|
-
*
|
|
1650
|
-
* id
|
|
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>/
|
|
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(
|
|
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(
|
|
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(
|
|
2101
|
+
.sort(byDateDesc);
|
|
2095
2102
|
|
|
2096
2103
|
return _allNotes;
|
|
2097
2104
|
}
|