@hutusi/amytis 1.5.5
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/.github/workflows/ci.yml +33 -0
- package/.github/workflows/publish.yml +53 -0
- package/AGENTS.md +41 -0
- package/CLAUDE.md +200 -0
- package/GEMINI.md +84 -0
- package/README.md +172 -0
- package/TODO.md +76 -0
- package/bun.lock +1530 -0
- package/content/about.mdx +23 -0
- package/content/books/sample-book/index.mdx +24 -0
- package/content/books/sample-book/introduction.mdx +34 -0
- package/content/books/sample-book/setup.mdx +48 -0
- package/content/books/sample-book/writing-content.mdx +49 -0
- package/content/flows/2026/02/05.md +8 -0
- package/content/flows/2026/02/10.mdx +8 -0
- package/content/flows/2026/02/15.md +8 -0
- package/content/flows/2026/02/18.mdx +14 -0
- package/content/posts/2026-01-12-the-art-of-algorithms.mdx +49 -0
- package/content/posts/2026-01-15-nested-image-test/images/test.svg +5 -0
- package/content/posts/2026-01-15-nested-image-test/index.mdx +27 -0
- package/content/posts/2026-01-21-kitchen-sink/assets/test.svg +5 -0
- package/content/posts/2026-01-21-kitchen-sink/index.mdx +169 -0
- package/content/posts/asynchronous-javascript.mdx +49 -0
- package/content/posts/draft-post.mdx +13 -0
- package/content/posts/future-post.mdx +12 -0
- package/content/posts/legacy-markdown.md +60 -0
- package/content/posts/markdown-features.mdx +78 -0
- package/content/posts/modern-css-layouts.mdx +45 -0
- package/content/posts/multilingual-test.mdx +124 -0
- package/content/posts/syntax-highlighting-showcase.mdx +528 -0
- package/content/posts/understanding-react-hooks.mdx +48 -0
- package/content/posts/welcome-to-amytis.mdx +21 -0
- package/content/posts//344/270/255/346/226/207/346/265/213/350/257/225/346/226/207/347/253/240.mdx +54 -0
- package/content/series/ai-nexus-weekly/index.mdx +10 -0
- package/content/series/ai-nexus-weekly/week-1.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-10.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-11.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-12.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-2.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-3.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-4.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-5.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-6.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-7.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-8.mdx +20 -0
- package/content/series/ai-nexus-weekly/week-9.mdx +20 -0
- package/content/series/digital-garden/01-philosophy/index.mdx +23 -0
- package/content/series/digital-garden/01-philosophy.mdx +30 -0
- package/content/series/digital-garden/02-architecture.mdx +19 -0
- package/content/series/digital-garden/index.mdx +11 -0
- package/content/series/markdown-showcase/index.mdx +11 -0
- package/content/series/markdown-showcase/mathematical-notation.mdx +32 -0
- package/content/series/markdown-showcase/syntax-highlighting.mdx +119 -0
- package/content/series/markdown-showcase/visuals-and-diagrams.mdx +27 -0
- package/content/series/nextjs-deep-dive/01-getting-started.mdx +66 -0
- package/content/series/nextjs-deep-dive/02-routing-mastery/assets/diagram.svg +8 -0
- package/content/series/nextjs-deep-dive/02-routing-mastery/assets/m-p-model.png +0 -0
- package/content/series/nextjs-deep-dive/02-routing-mastery/index.mdx +138 -0
- package/content/series/nextjs-deep-dive/index.mdx +12 -0
- package/docs/ARCHITECTURE.md +103 -0
- package/docs/CONTRIBUTING.md +86 -0
- package/docs/deployment.md +319 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +25 -0
- package/package.json +81 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon.svg +9 -0
- package/public/logo.svg +11 -0
- package/public/next-image-export-optimizer-hashes.json +7 -0
- package/public/next.svg +1 -0
- package/public/screenshot.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/copy-assets.ts +211 -0
- package/scripts/new-flow.ts +47 -0
- package/scripts/new-from-images.ts +141 -0
- package/scripts/new-from-pdf.ts +105 -0
- package/scripts/new-post.ts +98 -0
- package/scripts/new-series.ts +40 -0
- package/scripts/series-draft.ts +136 -0
- package/site.config.ts +91 -0
- package/src/app/[slug]/page.tsx +67 -0
- package/src/app/archive/page.tsx +147 -0
- package/src/app/authors/[author]/page.tsx +210 -0
- package/src/app/books/[slug]/[chapter]/page.tsx +54 -0
- package/src/app/books/[slug]/page.tsx +156 -0
- package/src/app/books/page.tsx +63 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/feed.xml/route.ts +44 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +105 -0
- package/src/app/flows/[year]/[month]/page.tsx +72 -0
- package/src/app/flows/[year]/page.tsx +82 -0
- package/src/app/flows/page/[page]/page.tsx +63 -0
- package/src/app/flows/page.tsx +38 -0
- package/src/app/globals.css +406 -0
- package/src/app/layout.tsx +114 -0
- package/src/app/page/[page]/page.tsx +60 -0
- package/src/app/page.tsx +110 -0
- package/src/app/posts/[slug]/page.tsx +119 -0
- package/src/app/posts/page/[page]/page.tsx +58 -0
- package/src/app/posts/page.tsx +40 -0
- package/src/app/search.json/route.ts +49 -0
- package/src/app/series/[slug]/page/[page]/page.tsx +141 -0
- package/src/app/series/[slug]/page.tsx +139 -0
- package/src/app/series/page.tsx +96 -0
- package/src/app/sitemap.ts +112 -0
- package/src/app/tags/[tag]/page.tsx +76 -0
- package/src/app/tags/page.tsx +37 -0
- package/src/components/Analytics.tsx +49 -0
- package/src/components/AuthorStats.tsx +34 -0
- package/src/components/BookMobileNav.tsx +171 -0
- package/src/components/BookSidebar.tsx +275 -0
- package/src/components/CodeBlock.tsx +110 -0
- package/src/components/Comments.tsx +63 -0
- package/src/components/CoverImage.tsx +93 -0
- package/src/components/CuratedSeriesSection.tsx +124 -0
- package/src/components/ExternalLinks.tsx +45 -0
- package/src/components/FeaturedStoriesSection.tsx +106 -0
- package/src/components/FlowCalendarSidebar.tsx +249 -0
- package/src/components/FlowContent.tsx +96 -0
- package/src/components/FlowTimelineEntry.tsx +34 -0
- package/src/components/Footer.tsx +104 -0
- package/src/components/Hero.tsx +126 -0
- package/src/components/HorizontalScroll.tsx +128 -0
- package/src/components/LanguageProvider.tsx +80 -0
- package/src/components/LanguageSwitch.tsx +17 -0
- package/src/components/LatestWritingSection.tsx +45 -0
- package/src/components/MarkdownRenderer.tsx +135 -0
- package/src/components/Mermaid.tsx +89 -0
- package/src/components/Navbar.tsx +243 -0
- package/src/components/PageHeader.tsx +39 -0
- package/src/components/Pagination.tsx +120 -0
- package/src/components/PostCard.tsx +30 -0
- package/src/components/PostList.tsx +104 -0
- package/src/components/PostSidebar.tsx +225 -0
- package/src/components/ReadingProgressBar.tsx +37 -0
- package/src/components/RecentNotesSection.tsx +56 -0
- package/src/components/RelatedPosts.tsx +34 -0
- package/src/components/Search.tsx +151 -0
- package/src/components/SelectedBooksSection.tsx +80 -0
- package/src/components/SeriesCatalog.tsx +112 -0
- package/src/components/SeriesList.tsx +167 -0
- package/src/components/SeriesSidebar.tsx +132 -0
- package/src/components/SimpleLayoutHeader.tsx +38 -0
- package/src/components/Skeleton.tsx +131 -0
- package/src/components/TableOfContents.tsx +158 -0
- package/src/components/Tag.tsx +47 -0
- package/src/components/TagPageHeader.tsx +38 -0
- package/src/components/ThemeProvider.tsx +12 -0
- package/src/components/ThemeToggle.tsx +68 -0
- package/src/components/TranslatedText.tsx +13 -0
- package/src/fonts/Inter-Bold.woff2 +0 -0
- package/src/fonts/Inter-Regular.woff2 +0 -0
- package/src/fonts/LibreBaskerville-Bold.ttf +0 -0
- package/src/fonts/LibreBaskerville-Italic.ttf +0 -0
- package/src/fonts/LibreBaskerville-Regular.ttf +0 -0
- package/src/i18n/translations.ts +135 -0
- package/src/layouts/BookLayout.tsx +109 -0
- package/src/layouts/PostLayout.tsx +118 -0
- package/src/layouts/SimpleLayout.tsx +31 -0
- package/src/lib/i18n.ts +35 -0
- package/src/lib/markdown.test.ts +127 -0
- package/src/lib/markdown.ts +1067 -0
- package/src/lib/rehype-image-metadata.ts +54 -0
- package/src/lib/shuffle.ts +11 -0
- package/templates/default.mdx +13 -0
- package/tests/e2e/navigation.test.ts +51 -0
- package/tests/e2e/series-routes.test.ts +63 -0
- package/tests/e2e/smoke.test.ts +19 -0
- package/tests/integration/markdown-features.test.ts +54 -0
- package/tests/integration/posts.test.ts +57 -0
- package/tests/integration/reading-time-headings.test.ts +79 -0
- package/tests/integration/series-draft.test.ts +46 -0
- package/tests/integration/series.test.ts +79 -0
- package/tests/tooling/new-from-images.test.ts +173 -0
- package/tests/tooling/new-post.test.ts +72 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { getAllAuthors, getAuthorSlug, getPostsByAuthor, resolveAuthorParam, getSeriesData, getSeriesPosts, getBooksByAuthor } from '@/lib/markdown';
|
|
2
|
+
import PostList from '@/components/PostList';
|
|
3
|
+
import Tag from '@/components/Tag';
|
|
4
|
+
import CoverImage from '@/components/CoverImage';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { notFound } from 'next/navigation';
|
|
7
|
+
import { Metadata } from 'next';
|
|
8
|
+
import { siteConfig } from '../../../../site.config';
|
|
9
|
+
import { t, resolveLocale } from '@/lib/i18n';
|
|
10
|
+
import AuthorStats from '@/components/AuthorStats';
|
|
11
|
+
import TranslatedText from '@/components/TranslatedText';
|
|
12
|
+
|
|
13
|
+
export async function generateStaticParams() {
|
|
14
|
+
const authors = getAllAuthors();
|
|
15
|
+
const params = new Set<string>();
|
|
16
|
+
|
|
17
|
+
// Generate slug-based routes and keep legacy name-based routes for compatibility.
|
|
18
|
+
for (const authorName of Object.keys(authors)) {
|
|
19
|
+
params.add(getAuthorSlug(authorName));
|
|
20
|
+
params.add(authorName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return [...params].map((author) => ({ author }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const dynamicParams = false;
|
|
27
|
+
|
|
28
|
+
export async function generateMetadata({ params }: { params: Promise<{ author: string }> }): Promise<Metadata> {
|
|
29
|
+
const { author: rawAuthor } = await params;
|
|
30
|
+
const decodedAuthorParam = decodeURIComponent(rawAuthor);
|
|
31
|
+
const resolvedAuthor = resolveAuthorParam(decodedAuthorParam);
|
|
32
|
+
|
|
33
|
+
if (!resolvedAuthor) {
|
|
34
|
+
return {
|
|
35
|
+
title: `Author Not Found | ${resolveLocale(siteConfig.title)}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const posts = getPostsByAuthor(resolvedAuthor);
|
|
40
|
+
return {
|
|
41
|
+
title: `${resolvedAuthor} | ${resolveLocale(siteConfig.title)}`,
|
|
42
|
+
description: `${posts.length} ${t('posts').toLowerCase()} ${t('written_by').toLowerCase()} ${resolvedAuthor}.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default async function AuthorPage({
|
|
47
|
+
params,
|
|
48
|
+
}: {
|
|
49
|
+
params: Promise<{ author: string }>;
|
|
50
|
+
}) {
|
|
51
|
+
const { author: rawAuthor } = await params;
|
|
52
|
+
const decodedAuthorParam = decodeURIComponent(rawAuthor);
|
|
53
|
+
const resolvedAuthor = resolveAuthorParam(decodedAuthorParam);
|
|
54
|
+
|
|
55
|
+
if (!resolvedAuthor) {
|
|
56
|
+
notFound();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const posts = getPostsByAuthor(resolvedAuthor);
|
|
60
|
+
|
|
61
|
+
if (posts.length === 0) {
|
|
62
|
+
notFound();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Collect unique tags and categories from this author's posts
|
|
66
|
+
const tags = new Map<string, number>();
|
|
67
|
+
const categories = new Set<string>();
|
|
68
|
+
for (const post of posts) {
|
|
69
|
+
categories.add(post.category);
|
|
70
|
+
for (const tag of post.tags) {
|
|
71
|
+
tags.set(tag, (tags.get(tag) || 0) + 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const topTags = [...tags.entries()]
|
|
75
|
+
.sort((a, b) => b[1] - a[1])
|
|
76
|
+
.slice(0, 8)
|
|
77
|
+
.map(([name]) => name);
|
|
78
|
+
|
|
79
|
+
// Collect series the author contributed to
|
|
80
|
+
const seriesSlugs = [...new Set(
|
|
81
|
+
posts.filter(p => p.series).map(p => p.series!)
|
|
82
|
+
)];
|
|
83
|
+
const authorSeries = seriesSlugs
|
|
84
|
+
.map(slug => {
|
|
85
|
+
const data = getSeriesData(slug);
|
|
86
|
+
const seriesPosts = getSeriesPosts(slug);
|
|
87
|
+
if (!data) return null;
|
|
88
|
+
return { slug, data, postCount: seriesPosts.length };
|
|
89
|
+
})
|
|
90
|
+
.filter((s): s is NonNullable<typeof s> => s !== null);
|
|
91
|
+
|
|
92
|
+
// Collect books the author wrote
|
|
93
|
+
const authorBooks = getBooksByAuthor(resolvedAuthor);
|
|
94
|
+
|
|
95
|
+
// Author initial for avatar
|
|
96
|
+
const initial = resolvedAuthor.charAt(0).toUpperCase();
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="layout-container">
|
|
100
|
+
<header className="mb-20 text-center">
|
|
101
|
+
{/* Author avatar */}
|
|
102
|
+
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-accent/10 border-2 border-accent/20">
|
|
103
|
+
<span className="text-3xl font-serif font-bold text-accent">
|
|
104
|
+
{initial}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<h1 className="text-4xl md:text-5xl font-serif font-bold text-heading mb-4">
|
|
109
|
+
{resolvedAuthor}
|
|
110
|
+
</h1>
|
|
111
|
+
|
|
112
|
+
{/* Stats */}
|
|
113
|
+
<AuthorStats
|
|
114
|
+
postCount={posts.length}
|
|
115
|
+
seriesCount={authorSeries.length}
|
|
116
|
+
categoryCount={categories.size}
|
|
117
|
+
bookCount={authorBooks.length}
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
{/* Top tags */}
|
|
121
|
+
{topTags.length > 0 && (
|
|
122
|
+
<div className="mt-8 flex flex-wrap justify-center gap-2">
|
|
123
|
+
{topTags.map(tag => (
|
|
124
|
+
<Tag key={tag} tag={tag} variant="default" />
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</header>
|
|
129
|
+
|
|
130
|
+
{/* Books */}
|
|
131
|
+
{authorBooks.length > 0 && (
|
|
132
|
+
<section className="mb-16">
|
|
133
|
+
<h2 className="text-2xl font-serif font-bold text-heading mb-8"><TranslatedText translationKey="books" /></h2>
|
|
134
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
135
|
+
{authorBooks.map(book => (
|
|
136
|
+
<Link key={book.slug} href={`/books/${book.slug}`} className="group block no-underline">
|
|
137
|
+
<div className="card-base h-full group flex flex-col p-0 overflow-hidden">
|
|
138
|
+
<div className="relative h-40 w-full overflow-hidden bg-muted/10">
|
|
139
|
+
<CoverImage
|
|
140
|
+
src={book.coverImage}
|
|
141
|
+
title={book.title}
|
|
142
|
+
slug={book.slug}
|
|
143
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="p-6">
|
|
147
|
+
<span className="badge-accent mb-3 inline-block">
|
|
148
|
+
{book.chapters.length} {t('chapters_count')}
|
|
149
|
+
</span>
|
|
150
|
+
<h3 className="mb-2 font-serif text-xl font-bold text-heading group-hover:text-accent transition-colors">
|
|
151
|
+
{book.title}
|
|
152
|
+
</h3>
|
|
153
|
+
{book.excerpt && (
|
|
154
|
+
<p className="text-sm text-muted font-serif italic leading-relaxed line-clamp-2">
|
|
155
|
+
{book.excerpt}
|
|
156
|
+
</p>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</Link>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{/* Series contributions */}
|
|
167
|
+
{authorSeries.length > 0 && (
|
|
168
|
+
<section className="mb-16">
|
|
169
|
+
<h2 className="text-2xl font-serif font-bold text-heading mb-8"><TranslatedText translationKey="series" /></h2>
|
|
170
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
171
|
+
{authorSeries.map(({ slug, data, postCount }) => (
|
|
172
|
+
<Link key={slug} href={`/series/${slug}`} className="group block no-underline">
|
|
173
|
+
<div className="card-base h-full group flex flex-col p-0 overflow-hidden">
|
|
174
|
+
<div className="relative h-40 w-full overflow-hidden bg-muted/10">
|
|
175
|
+
<CoverImage
|
|
176
|
+
src={data.coverImage}
|
|
177
|
+
title={data.title}
|
|
178
|
+
slug={slug}
|
|
179
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="p-6">
|
|
183
|
+
<span className="badge-accent mb-3 inline-block">
|
|
184
|
+
{postCount} {t('parts')}
|
|
185
|
+
</span>
|
|
186
|
+
<h3 className="mb-2 font-serif text-xl font-bold text-heading group-hover:text-accent transition-colors">
|
|
187
|
+
{data.title}
|
|
188
|
+
</h3>
|
|
189
|
+
{data.excerpt && (
|
|
190
|
+
<p className="text-sm text-muted font-serif italic leading-relaxed line-clamp-2">
|
|
191
|
+
{data.excerpt}
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</Link>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
</section>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<section>
|
|
203
|
+
{authorSeries.length > 0 && (
|
|
204
|
+
<h2 className="text-2xl font-serif font-bold text-heading mb-8"><TranslatedText translationKey="posts" /></h2>
|
|
205
|
+
)}
|
|
206
|
+
<PostList posts={posts} />
|
|
207
|
+
</section>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getBookData, getBookChapter, getAllBooks } from '@/lib/markdown';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { Metadata } from 'next';
|
|
4
|
+
import { siteConfig } from '../../../../../site.config';
|
|
5
|
+
import BookLayout from '@/layouts/BookLayout';
|
|
6
|
+
import { resolveLocale } from '@/lib/i18n';
|
|
7
|
+
|
|
8
|
+
export async function generateStaticParams() {
|
|
9
|
+
const books = getAllBooks();
|
|
10
|
+
const params: { slug: string; chapter: string }[] = [];
|
|
11
|
+
|
|
12
|
+
for (const book of books) {
|
|
13
|
+
for (const ch of book.chapters) {
|
|
14
|
+
params.push({ slug: book.slug, chapter: ch.file });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return params;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const dynamicParams = false;
|
|
22
|
+
|
|
23
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string; chapter: string }> }): Promise<Metadata> {
|
|
24
|
+
const { slug: rawSlug, chapter: rawChapter } = await params;
|
|
25
|
+
const slug = decodeURIComponent(rawSlug);
|
|
26
|
+
const chapterSlug = decodeURIComponent(rawChapter);
|
|
27
|
+
|
|
28
|
+
const book = getBookData(slug);
|
|
29
|
+
const chapter = getBookChapter(slug, chapterSlug);
|
|
30
|
+
|
|
31
|
+
if (!book || !chapter) {
|
|
32
|
+
return { title: 'Chapter Not Found' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
title: `${chapter.title} - ${book.title} | ${resolveLocale(siteConfig.title)}`,
|
|
37
|
+
description: chapter.excerpt,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default async function BookChapterPage({ params }: { params: Promise<{ slug: string; chapter: string }> }) {
|
|
42
|
+
const { slug: rawSlug, chapter: rawChapter } = await params;
|
|
43
|
+
const slug = decodeURIComponent(rawSlug);
|
|
44
|
+
const chapterSlug = decodeURIComponent(rawChapter);
|
|
45
|
+
|
|
46
|
+
const book = getBookData(slug);
|
|
47
|
+
const chapter = getBookChapter(slug, chapterSlug);
|
|
48
|
+
|
|
49
|
+
if (!book || !chapter) {
|
|
50
|
+
notFound();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return <BookLayout book={book} chapter={chapter} />;
|
|
54
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { getBookData, getAllBooks, getAuthorSlug } from '@/lib/markdown';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { Metadata } from 'next';
|
|
4
|
+
import { siteConfig } from '../../../../site.config';
|
|
5
|
+
import CoverImage from '@/components/CoverImage';
|
|
6
|
+
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
7
|
+
import Link from 'next/link';
|
|
8
|
+
import { t, resolveLocale } from '@/lib/i18n';
|
|
9
|
+
|
|
10
|
+
export async function generateStaticParams() {
|
|
11
|
+
const books = getAllBooks();
|
|
12
|
+
return books.map(book => ({ slug: book.slug }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const dynamicParams = false;
|
|
16
|
+
|
|
17
|
+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
|
18
|
+
const { slug } = await params;
|
|
19
|
+
const book = getBookData(decodeURIComponent(slug));
|
|
20
|
+
|
|
21
|
+
if (!book) {
|
|
22
|
+
return { title: 'Book Not Found' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
title: `${book.title} | ${resolveLocale(siteConfig.title)}`,
|
|
27
|
+
description: book.excerpt,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default async function BookLandingPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
32
|
+
const { slug: rawSlug } = await params;
|
|
33
|
+
const slug = decodeURIComponent(rawSlug);
|
|
34
|
+
const book = getBookData(slug);
|
|
35
|
+
|
|
36
|
+
if (!book || (process.env.NODE_ENV === 'production' && book.draft)) {
|
|
37
|
+
notFound();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const firstChapter = book.chapters.length > 0 ? book.chapters[0] : null;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="layout-main">
|
|
44
|
+
<header className="mb-16">
|
|
45
|
+
{/* Cover image */}
|
|
46
|
+
{book.coverImage && (
|
|
47
|
+
<div className="relative w-full h-56 md:h-72 mb-10 rounded-2xl overflow-hidden shadow-xl shadow-accent/5">
|
|
48
|
+
<CoverImage
|
|
49
|
+
src={book.coverImage}
|
|
50
|
+
title={book.title}
|
|
51
|
+
slug={book.slug}
|
|
52
|
+
className="w-full h-full object-cover"
|
|
53
|
+
/>
|
|
54
|
+
<div className="absolute inset-0 bg-gradient-to-t from-background/60 to-transparent" />
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
<div className="text-center max-w-2xl mx-auto">
|
|
59
|
+
<span className="badge-accent mb-4">
|
|
60
|
+
{t('book')} • {book.chapters.length} {t('chapters_count')}
|
|
61
|
+
</span>
|
|
62
|
+
<h1 className="page-title mb-4">{book.title}</h1>
|
|
63
|
+
{book.excerpt && (
|
|
64
|
+
<p className="text-lg text-muted font-serif italic leading-relaxed">
|
|
65
|
+
{book.excerpt}
|
|
66
|
+
</p>
|
|
67
|
+
)}
|
|
68
|
+
{book.authors.length > 0 && (
|
|
69
|
+
<p className="mt-4 text-sm text-muted">
|
|
70
|
+
<span className="mr-1">{t('written_by')}</span>
|
|
71
|
+
{book.authors.map((author, index) => (
|
|
72
|
+
<span key={author}>
|
|
73
|
+
<Link
|
|
74
|
+
href={`/authors/${getAuthorSlug(author)}`}
|
|
75
|
+
className="text-foreground hover:text-accent no-underline transition-colors duration-200"
|
|
76
|
+
>
|
|
77
|
+
{author}
|
|
78
|
+
</Link>
|
|
79
|
+
{index < book.authors.length - 1 && <span className="mr-1">,</span>}
|
|
80
|
+
</span>
|
|
81
|
+
))}
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Start Reading CTA */}
|
|
86
|
+
{firstChapter && (
|
|
87
|
+
<div className="mt-8">
|
|
88
|
+
<Link
|
|
89
|
+
href={`/books/${book.slug}/${firstChapter.file}`}
|
|
90
|
+
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-xl font-sans font-medium text-sm hover:bg-accent/90 no-underline transition-colors shadow-lg shadow-accent/20"
|
|
91
|
+
>
|
|
92
|
+
{t('start_reading')}
|
|
93
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
94
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
95
|
+
</svg>
|
|
96
|
+
</Link>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</header>
|
|
101
|
+
|
|
102
|
+
{/* Table of Contents */}
|
|
103
|
+
<section className="max-w-2xl mx-auto mb-16">
|
|
104
|
+
<h2 className="text-xl font-serif font-bold text-heading mb-8">{t('chapters_count')}</h2>
|
|
105
|
+
<div className="space-y-6">
|
|
106
|
+
{book.toc.map((item, idx) => {
|
|
107
|
+
if ('part' in item) {
|
|
108
|
+
return (
|
|
109
|
+
<div key={`part-${idx}`}>
|
|
110
|
+
<h3 className="text-sm font-sans font-bold uppercase tracking-wider text-muted mb-3">
|
|
111
|
+
{item.part}
|
|
112
|
+
</h3>
|
|
113
|
+
<ol className="space-y-2 pl-4 border-l-2 border-muted/10">
|
|
114
|
+
{item.chapters.map(ch => (
|
|
115
|
+
<li key={ch.file}>
|
|
116
|
+
<Link
|
|
117
|
+
href={`/books/${book.slug}/${ch.file}`}
|
|
118
|
+
className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
|
|
119
|
+
>
|
|
120
|
+
<svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
121
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
122
|
+
</svg>
|
|
123
|
+
<span className="text-base">{ch.title}</span>
|
|
124
|
+
</Link>
|
|
125
|
+
</li>
|
|
126
|
+
))}
|
|
127
|
+
</ol>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
} else {
|
|
131
|
+
return (
|
|
132
|
+
<Link
|
|
133
|
+
key={item.file}
|
|
134
|
+
href={`/books/${book.slug}/${item.file}`}
|
|
135
|
+
className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
|
|
136
|
+
>
|
|
137
|
+
<svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
138
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
139
|
+
</svg>
|
|
140
|
+
<span className="text-base">{item.title}</span>
|
|
141
|
+
</Link>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
})}
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
|
|
148
|
+
{/* Book body content */}
|
|
149
|
+
{book.content && (
|
|
150
|
+
<section className="max-w-2xl mx-auto">
|
|
151
|
+
<MarkdownRenderer content={book.content} slug={`books/${book.slug}`} />
|
|
152
|
+
</section>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getAllBooks } from '@/lib/markdown';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { siteConfig } from '../../../site.config';
|
|
4
|
+
import { Metadata } from 'next';
|
|
5
|
+
import CoverImage from '@/components/CoverImage';
|
|
6
|
+
import { t, resolveLocale } from '@/lib/i18n';
|
|
7
|
+
import PageHeader from '@/components/PageHeader';
|
|
8
|
+
|
|
9
|
+
export const metadata: Metadata = {
|
|
10
|
+
title: `${t('books')} | ${resolveLocale(siteConfig.title)}`,
|
|
11
|
+
description: 'Structured long-form books and guides.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default function BooksPage() {
|
|
15
|
+
const books = getAllBooks();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="layout-main">
|
|
19
|
+
<PageHeader
|
|
20
|
+
titleKey="books"
|
|
21
|
+
subtitleKey="series_subtitle"
|
|
22
|
+
subtitleOneKey="series_subtitle_one"
|
|
23
|
+
count={books.length}
|
|
24
|
+
subtitleParams={{ count: books.length }}
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
28
|
+
{books.map(book => (
|
|
29
|
+
<Link key={book.slug} href={`/books/${book.slug}`} className="group block no-underline">
|
|
30
|
+
<div className="card-base h-full group flex flex-col p-0 overflow-hidden">
|
|
31
|
+
<div className="relative h-48 w-full overflow-hidden bg-muted/10">
|
|
32
|
+
<CoverImage
|
|
33
|
+
src={book.coverImage}
|
|
34
|
+
title={book.title}
|
|
35
|
+
slug={book.slug}
|
|
36
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<div className="p-8">
|
|
40
|
+
<span className="badge-accent mb-4 inline-block">
|
|
41
|
+
{book.chapters.length} {t('chapters_count')}
|
|
42
|
+
</span>
|
|
43
|
+
<h2 className="mb-3 font-serif text-2xl font-bold text-heading group-hover:text-accent transition-colors">
|
|
44
|
+
{book.title}
|
|
45
|
+
</h2>
|
|
46
|
+
{book.authors.length > 0 && (
|
|
47
|
+
<p className="text-xs text-muted mb-3">
|
|
48
|
+
{t('written_by')} {book.authors.slice(0, 3).join(', ')}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
{book.excerpt && (
|
|
52
|
+
<p className="text-muted font-serif italic leading-relaxed line-clamp-3">
|
|
53
|
+
{book.excerpt}
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</Link>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getAllPosts } from '@/lib/markdown';
|
|
2
|
+
import { siteConfig } from '../../../site.config';
|
|
3
|
+
import { resolveLocale } from '@/lib/i18n';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-static';
|
|
6
|
+
|
|
7
|
+
export async function GET() {
|
|
8
|
+
const posts = getAllPosts();
|
|
9
|
+
const baseUrl = siteConfig.baseUrl;
|
|
10
|
+
|
|
11
|
+
const rssItemsXml = posts
|
|
12
|
+
.map((post) => {
|
|
13
|
+
const url = `${baseUrl}/posts/${post.slug}`;
|
|
14
|
+
return `
|
|
15
|
+
<item>
|
|
16
|
+
<title><![CDATA[${post.title}]]></title>
|
|
17
|
+
<link>${url}</link>
|
|
18
|
+
<guid>${url}</guid>
|
|
19
|
+
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
|
|
20
|
+
<description><![CDATA[${post.excerpt}]]></description>
|
|
21
|
+
${post.tags ? post.tags.map(tag => `<category>${tag}</category>`).join('') : ''}
|
|
22
|
+
</item>`;
|
|
23
|
+
})
|
|
24
|
+
.join('');
|
|
25
|
+
|
|
26
|
+
const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
27
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
28
|
+
<channel>
|
|
29
|
+
<title><![CDATA[${resolveLocale(siteConfig.title)}]]></title>
|
|
30
|
+
<link>${baseUrl}</link>
|
|
31
|
+
<description><![CDATA[${resolveLocale(siteConfig.description)}]]></description>
|
|
32
|
+
<language>en</language>
|
|
33
|
+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
|
34
|
+
<atom:link href="${baseUrl}/feed.xml" rel="self" type="application/rss+xml" />
|
|
35
|
+
${rssItemsXml}
|
|
36
|
+
</channel>
|
|
37
|
+
</rss>`;
|
|
38
|
+
|
|
39
|
+
return new Response(rssXml, {
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/xml',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getAllFlows, getFlowBySlug, getAdjacentFlows } from '@/lib/markdown';
|
|
2
|
+
import { siteConfig } from '../../../../../../site.config';
|
|
3
|
+
import { Metadata } from 'next';
|
|
4
|
+
import { notFound } from 'next/navigation';
|
|
5
|
+
import { t, resolveLocale } from '@/lib/i18n';
|
|
6
|
+
import FlowCalendarSidebar from '@/components/FlowCalendarSidebar';
|
|
7
|
+
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
8
|
+
import Link from 'next/link';
|
|
9
|
+
|
|
10
|
+
export function generateStaticParams() {
|
|
11
|
+
const allFlows = getAllFlows();
|
|
12
|
+
return allFlows.map(flow => {
|
|
13
|
+
const [year, month, day] = flow.slug.split('/');
|
|
14
|
+
return { year, month, day };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const dynamicParams = false;
|
|
19
|
+
|
|
20
|
+
export async function generateMetadata({ params }: { params: Promise<{ year: string; month: string; day: string }> }): Promise<Metadata> {
|
|
21
|
+
const { year, month, day } = await params;
|
|
22
|
+
const flow = getFlowBySlug(`${year}/${month}/${day}`);
|
|
23
|
+
if (!flow) return { title: 'Not Found' };
|
|
24
|
+
return {
|
|
25
|
+
title: `${flow.title} | ${resolveLocale(siteConfig.title)}`,
|
|
26
|
+
description: flow.excerpt,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default async function FlowPage({ params }: { params: Promise<{ year: string; month: string; day: string }> }) {
|
|
31
|
+
const { year, month, day } = await params;
|
|
32
|
+
const slug = `${year}/${month}/${day}`;
|
|
33
|
+
const flow = getFlowBySlug(slug);
|
|
34
|
+
if (!flow) notFound();
|
|
35
|
+
|
|
36
|
+
const allFlows = getAllFlows();
|
|
37
|
+
const entryDates = allFlows.map(f => f.date);
|
|
38
|
+
const { prev, next } = getAdjacentFlows(flow.slug);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="layout-main">
|
|
42
|
+
{/* Breadcrumb navigation */}
|
|
43
|
+
<nav className="flex items-center gap-1.5 text-sm text-muted mb-6">
|
|
44
|
+
<Link href="/flows" className="hover:text-accent no-underline">
|
|
45
|
+
{t('all_flows')}
|
|
46
|
+
</Link>
|
|
47
|
+
<span className="text-muted/40">›</span>
|
|
48
|
+
<Link href={`/flows/${year}`} className="hover:text-accent no-underline">
|
|
49
|
+
{year}
|
|
50
|
+
</Link>
|
|
51
|
+
<span className="text-muted/40">›</span>
|
|
52
|
+
<Link href={`/flows/${year}/${month}`} className="hover:text-accent no-underline">
|
|
53
|
+
{month}
|
|
54
|
+
</Link>
|
|
55
|
+
<span className="text-muted/40">›</span>
|
|
56
|
+
<span className="text-foreground">{day}</span>
|
|
57
|
+
</nav>
|
|
58
|
+
|
|
59
|
+
<div className="flex gap-10">
|
|
60
|
+
<FlowCalendarSidebar entryDates={entryDates} currentDate={flow.date} />
|
|
61
|
+
|
|
62
|
+
<article className="flex-1 min-w-0">
|
|
63
|
+
{/* Header */}
|
|
64
|
+
<header className="mb-8">
|
|
65
|
+
<time className="text-sm font-mono text-accent">{flow.date}</time>
|
|
66
|
+
<h1 className="mt-2 text-3xl md:text-4xl font-serif font-bold text-heading">{flow.title}</h1>
|
|
67
|
+
</header>
|
|
68
|
+
|
|
69
|
+
{/* Content */}
|
|
70
|
+
<div className="prose prose-lg dark:prose-invert max-w-none">
|
|
71
|
+
<MarkdownRenderer content={flow.content} />
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Prev/Next navigation */}
|
|
75
|
+
<nav className="mt-16 pt-8 border-t border-muted/20 grid grid-cols-2 gap-4">
|
|
76
|
+
{prev ? (
|
|
77
|
+
<Link
|
|
78
|
+
href={`/flows/${prev.slug}`}
|
|
79
|
+
className="group text-left no-underline"
|
|
80
|
+
>
|
|
81
|
+
<span className="text-xs text-muted">Older</span>
|
|
82
|
+
<div className="text-sm font-medium text-heading group-hover:text-accent transition-colors truncate">
|
|
83
|
+
{prev.title}
|
|
84
|
+
</div>
|
|
85
|
+
<span className="text-xs font-mono text-muted">{prev.date}</span>
|
|
86
|
+
</Link>
|
|
87
|
+
) : <div />}
|
|
88
|
+
{next ? (
|
|
89
|
+
<Link
|
|
90
|
+
href={`/flows/${next.slug}`}
|
|
91
|
+
className="group text-right no-underline"
|
|
92
|
+
>
|
|
93
|
+
<span className="text-xs text-muted">Newer</span>
|
|
94
|
+
<div className="text-sm font-medium text-heading group-hover:text-accent transition-colors truncate">
|
|
95
|
+
{next.title}
|
|
96
|
+
</div>
|
|
97
|
+
<span className="text-xs font-mono text-muted">{next.date}</span>
|
|
98
|
+
</Link>
|
|
99
|
+
) : <div />}
|
|
100
|
+
</nav>
|
|
101
|
+
</article>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|