@hutusi/amytis 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.entire/settings.json +4 -0
- package/AGENTS.md +13 -7
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +30 -2
- package/README.md +84 -58
- package/README.zh.md +195 -0
- package/TODO.md +6 -4
- package/bun.lock +30 -4
- package/content/books/sample-book/images/digital-garden.svg +25 -0
- package/content/books/sample-book/index.mdx +1 -1
- package/content/books/sample-book/introduction.mdx +2 -0
- package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
- package/content/posts/legacy-markdown.md +0 -2
- package/content/posts/markdown-features.mdx +1 -1
- package/content/posts/multilingual-test.mdx +0 -1
- package/content/posts/syntax-highlighting-showcase.mdx +1 -0
- package/content/posts/understanding-react-hooks.mdx +1 -0
- package/content/series/ai-nexus-weekly/index.mdx +1 -1
- package/content/series/digital-garden/01-philosophy.mdx +2 -0
- package/content/series/digital-garden/index.mdx +1 -1
- package/content/series/markdown-showcase/index.mdx +1 -1
- package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
- package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
- package/content/series/nextjs-deep-dive/index.mdx +1 -1
- package/content/subscribe.mdx +27 -0
- package/docs/ARCHITECTURE.md +85 -105
- package/docs/CONTRIBUTING.md +11 -3
- package/docs/DIGITAL_GARDEN.md +11 -9
- package/docs/deployment.md +31 -13
- package/eslint.config.mjs +2 -0
- package/package.json +13 -4
- package/packages/create-amytis/package.json +13 -0
- package/packages/create-amytis/src/index.test.ts +187 -0
- package/packages/create-amytis/src/index.ts +230 -0
- package/packages/create-amytis/tsconfig.json +14 -0
- package/public/images/amytis-screenshot.jpg +0 -0
- package/public/images/antelope-canyon.jpg +0 -0
- package/public/images/avatar.jpg +0 -0
- package/public/images/cappadocia.jpg +0 -0
- package/public/images/flowers.jpg +0 -0
- package/public/images/galaxy.jpg +0 -0
- package/public/images/lake.jpg +0 -0
- package/public/images/mountains.jpg +0 -0
- package/public/images/vibrant-waves.avif +0 -0
- package/public/images/wechat-qr.jpg +0 -0
- package/public/next-image-export-optimizer-hashes.json +72 -2
- package/scripts/deploy.ts +77 -0
- package/site.config.example.ts +268 -0
- package/site.config.ts +82 -10
- package/src/app/[slug]/[postSlug]/page.tsx +161 -0
- package/src/app/[slug]/page/[page]/page.tsx +202 -0
- package/src/app/[slug]/page.tsx +162 -7
- package/src/app/archive/page.tsx +5 -8
- package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
- package/src/app/books/[slug]/page.tsx +16 -2
- package/src/app/books/page.tsx +12 -8
- package/src/app/feed.atom/route.ts +62 -0
- package/src/app/feed.xml/route.ts +45 -20
- package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
- package/src/app/flows/[year]/page.tsx +1 -1
- package/src/app/flows/page/[page]/page.tsx +2 -0
- package/src/app/flows/page.tsx +4 -0
- package/src/app/globals.css +9 -3
- package/src/app/layout.tsx +22 -1
- package/src/app/notes/[slug]/page.tsx +2 -3
- package/src/app/page.tsx +86 -8
- package/src/app/posts/[slug]/page.tsx +33 -7
- package/src/app/posts/page/[page]/page.tsx +9 -5
- package/src/app/posts/page.tsx +13 -7
- package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
- package/src/app/series/[slug]/page.tsx +21 -19
- package/src/app/series/page.tsx +14 -28
- package/src/app/sitemap.ts +2 -1
- package/src/app/tags/[tag]/page.tsx +1 -1
- package/src/app/tags/page.tsx +1 -1
- package/src/components/Analytics.tsx +34 -36
- package/src/components/AuthorCard.tsx +56 -14
- package/src/components/BookMobileNav.tsx +8 -34
- package/src/components/BookSidebar.tsx +17 -107
- package/src/components/CodeBlock.test.tsx +19 -0
- package/src/components/CodeBlock.tsx +20 -16
- package/src/components/Comments.tsx +4 -3
- package/src/components/CoverImage.tsx +11 -3
- package/src/components/CuratedSeriesSection.tsx +17 -15
- package/src/components/ExternalLinks.tsx +1 -1
- package/src/components/FeaturedStoriesSection.tsx +124 -58
- package/src/components/FlowCalendarSidebar.tsx +10 -7
- package/src/components/FlowContent.tsx +33 -5
- package/src/components/Footer.tsx +28 -6
- package/src/components/Hero.tsx +44 -92
- package/src/components/InlineBookToc.tsx +56 -0
- package/src/components/KnowledgeGraph.tsx +26 -0
- package/src/components/LanguageProvider.tsx +7 -2
- package/src/components/LanguageSwitch.tsx +1 -1
- package/src/components/LatestWritingSection.tsx +12 -22
- package/src/components/MarkdownRenderer.test.tsx +41 -0
- package/src/components/MarkdownRenderer.tsx +43 -8
- package/src/components/Navbar.tsx +24 -15
- package/src/components/NoteSidebar.tsx +6 -76
- package/src/components/Pagination.tsx +2 -2
- package/src/components/PostCard.tsx +2 -1
- package/src/components/PostList.tsx +81 -62
- package/src/components/PostNavigation.tsx +8 -7
- package/src/components/PostSidebar.tsx +12 -107
- package/src/components/PrevNextNav.tsx +63 -0
- package/src/components/RecentNotesSection.tsx +2 -2
- package/src/components/RelatedPosts.tsx +3 -2
- package/src/components/RssFeedWidget.tsx +55 -0
- package/src/components/SectionLabel.tsx +16 -0
- package/src/components/SelectedBooksSection.tsx +12 -15
- package/src/components/SeriesCatalog.tsx +74 -69
- package/src/components/SeriesList.tsx +11 -36
- package/src/components/SeriesSidebar.tsx +4 -2
- package/src/components/TagContentTabs.tsx +17 -12
- package/src/components/TagPageHeader.tsx +12 -2
- package/src/components/TagSidebar.tsx +3 -2
- package/src/components/TagsIndexClient.tsx +1 -1
- package/src/components/TocPanel.tsx +67 -0
- package/src/hooks/useActiveHeading.ts +32 -0
- package/src/hooks/useSidebarAutoScroll.ts +19 -0
- package/src/i18n/translations.ts +26 -4
- package/src/layouts/BookLayout.tsx +10 -40
- package/src/layouts/PostLayout.tsx +37 -26
- package/src/layouts/SimpleLayout.tsx +5 -5
- package/src/lib/feed-utils.ts +68 -0
- package/src/lib/format-utils.test.ts +27 -0
- package/src/lib/format-utils.ts +3 -0
- package/src/lib/image-utils.ts +12 -0
- package/src/lib/json-ld.ts +236 -0
- package/src/lib/markdown.ts +62 -7
- package/src/lib/rehype-image-metadata.test.ts +124 -0
- package/src/lib/rehype-image-metadata.ts +21 -5
- package/src/lib/scroll-utils.ts +11 -0
- package/src/lib/shuffle.ts +21 -0
- package/src/lib/urls.ts +51 -0
- package/tests/e2e/navigation.test.ts +15 -1
- package/tests/integration/books.test.ts +61 -0
- package/tests/integration/feed-utils.test.ts +145 -0
- package/tests/integration/series.test.ts +9 -0
- package/tests/unit/static-params.test.ts +59 -2
- package/src/app/subscribe/page.tsx +0 -17
- package/src/components/SubscribePage.tsx +0 -298
- /package/public/{screenshot.png → images/screenshot.png} +0 -0
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getListingPosts } from '@/lib/markdown';
|
|
2
2
|
import PostList from '@/components/PostList';
|
|
3
3
|
import Pagination from '@/components/Pagination';
|
|
4
4
|
import { siteConfig } from '../../../../../site.config';
|
|
5
5
|
import { Metadata } from 'next';
|
|
6
6
|
import { notFound } from 'next/navigation';
|
|
7
|
-
import { t, resolveLocale } from '@/lib/i18n';
|
|
7
|
+
import { t, resolveLocale, tWith } from '@/lib/i18n';
|
|
8
8
|
import PageHeader from '@/components/PageHeader';
|
|
9
|
+
import { getPostsBasePath } from '@/lib/urls';
|
|
9
10
|
|
|
10
11
|
const PAGE_SIZE = siteConfig.pagination.posts;
|
|
11
12
|
|
|
12
13
|
export function generateStaticParams() {
|
|
13
|
-
|
|
14
|
+
if (getPostsBasePath() !== 'posts') return [{ page: '_' }]; // Route disabled; custom path handles this
|
|
15
|
+
const allPosts = getListingPosts();
|
|
14
16
|
const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
|
|
15
17
|
|
|
16
18
|
// Generate params for page 2 to totalPages (page 1 is handled by /posts/page.tsx)
|
|
@@ -25,15 +27,17 @@ export const dynamicParams = false;
|
|
|
25
27
|
|
|
26
28
|
export async function generateMetadata({ params }: { params: Promise<{ page: string }> }): Promise<Metadata> {
|
|
27
29
|
const { page } = await params;
|
|
30
|
+
const allPosts = getListingPosts();
|
|
31
|
+
const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
|
|
28
32
|
return {
|
|
29
|
-
title: `${t('posts')} - ${page} | ${resolveLocale(siteConfig.title)}`,
|
|
33
|
+
title: `${t('posts')} - ${tWith('page_of_total', { page: parseInt(page), total: totalPages })} | ${resolveLocale(siteConfig.title)}`,
|
|
30
34
|
};
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export default async function PostsPage({ params }: { params: Promise<{ page: string }> }) {
|
|
34
38
|
const { page: pageStr } = await params;
|
|
35
39
|
const page = parseInt(pageStr);
|
|
36
|
-
const allPosts =
|
|
40
|
+
const allPosts = getListingPosts();
|
|
37
41
|
const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
|
|
38
42
|
|
|
39
43
|
if (isNaN(page) || page < 2 || page > totalPages) notFound();
|
package/src/app/posts/page.tsx
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getListingPosts } from '@/lib/markdown';
|
|
2
2
|
import PostList from '@/components/PostList';
|
|
3
3
|
import Pagination from '@/components/Pagination';
|
|
4
4
|
import { siteConfig } from '../../../site.config';
|
|
5
5
|
import { Metadata } from 'next';
|
|
6
|
-
import { t, resolveLocale } from '@/lib/i18n';
|
|
6
|
+
import { t, resolveLocale, tWith } from '@/lib/i18n';
|
|
7
7
|
import PageHeader from '@/components/PageHeader';
|
|
8
|
+
import { getPostsBasePath } from '@/lib/urls';
|
|
9
|
+
import { notFound } from 'next/navigation';
|
|
8
10
|
|
|
9
11
|
const PAGE_SIZE = siteConfig.pagination.posts;
|
|
10
12
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
13
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
14
|
+
const allPosts = getListingPosts();
|
|
15
|
+
return {
|
|
16
|
+
title: `${t('posts')} | ${resolveLocale(siteConfig.title)}`,
|
|
17
|
+
description: tWith('posts_subtitle', { count: allPosts.length }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
15
20
|
|
|
16
21
|
export default function AllPostsPage() {
|
|
17
|
-
|
|
22
|
+
if (getPostsBasePath() !== 'posts') notFound();
|
|
23
|
+
const allPosts = getListingPosts();
|
|
18
24
|
const page = 1;
|
|
19
25
|
const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
|
|
20
26
|
const posts = allPosts.slice(0, PAGE_SIZE);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getSeriesData, getSeriesPosts, getAllSeries,
|
|
1
|
+
import { getSeriesData, getSeriesPosts, getAllSeries, resolveSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import SeriesCatalog from '@/components/SeriesCatalog';
|
|
4
4
|
import Pagination from '@/components/Pagination';
|
|
@@ -6,7 +6,7 @@ import { Metadata } from 'next';
|
|
|
6
6
|
import { siteConfig } from '../../../../../../site.config';
|
|
7
7
|
import CoverImage from '@/components/CoverImage';
|
|
8
8
|
import Link from 'next/link';
|
|
9
|
-
import { t, resolveLocale } from '@/lib/i18n';
|
|
9
|
+
import { t, resolveLocale, tWith } from '@/lib/i18n';
|
|
10
10
|
|
|
11
11
|
const PAGE_SIZE = siteConfig.pagination.series;
|
|
12
12
|
|
|
@@ -34,8 +34,10 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
34
34
|
const slug = decodeURIComponent(rawSlug);
|
|
35
35
|
const seriesData = getSeriesData(slug);
|
|
36
36
|
const title = seriesData?.title || slug;
|
|
37
|
+
const allPosts = getSeriesPosts(slug);
|
|
38
|
+
const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
|
|
37
39
|
return {
|
|
38
|
-
title: `${title} - ${page} | ${resolveLocale(siteConfig.title)}`,
|
|
40
|
+
title: `${title} - ${tWith('page_of_total', { page, total: totalPages })} | ${resolveLocale(siteConfig.title)}`,
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -61,24 +63,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
|
|
61
63
|
const description = seriesData?.excerpt;
|
|
62
64
|
const coverImage = seriesData?.coverImage;
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
const explicitAuthors = getSeriesAuthors(slug);
|
|
66
|
-
let authors: string[];
|
|
67
|
-
if (explicitAuthors) {
|
|
68
|
-
authors = explicitAuthors;
|
|
69
|
-
} else if (allPosts.length > 0) {
|
|
70
|
-
const counts = new Map<string, number>();
|
|
71
|
-
for (const post of allPosts) {
|
|
72
|
-
for (const author of post.authors) {
|
|
73
|
-
counts.set(author, (counts.get(author) || 0) + 1);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
authors = [...counts.entries()]
|
|
77
|
-
.sort((a, b) => b[1] - a[1])
|
|
78
|
-
.map(([name]) => name);
|
|
79
|
-
} else {
|
|
80
|
-
authors = [];
|
|
81
|
-
}
|
|
66
|
+
const authors = resolveSeriesAuthors(slug, allPosts);
|
|
82
67
|
|
|
83
68
|
// Calculate the starting index for this page
|
|
84
69
|
const startIndex = (page - 1) * PAGE_SIZE;
|
|
@@ -103,10 +88,8 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
|
|
103
88
|
<span className="badge-accent mb-4">
|
|
104
89
|
{t('series')} • {allPosts.length} {t('parts')}
|
|
105
90
|
</span>
|
|
106
|
-
<h1 className="page-title mb-
|
|
107
|
-
|
|
108
|
-
<span className="block text-lg text-muted font-sans font-normal mt-2">{page} / {totalPages}</span>
|
|
109
|
-
</h1>
|
|
91
|
+
<h1 className="page-title mb-2">{title}</h1>
|
|
92
|
+
<p className="text-base text-muted font-sans mt-1 mb-4">{tWith('page_of_total', { page, total: totalPages })}</p>
|
|
110
93
|
{description && (
|
|
111
94
|
<p className="text-lg text-muted font-serif italic leading-relaxed">
|
|
112
95
|
{description}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getSeriesData, getSeriesPosts, getAllSeries,
|
|
1
|
+
import { getSeriesData, getSeriesPosts, getAllSeries, resolveSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import SeriesCatalog from '@/components/SeriesCatalog';
|
|
4
4
|
import Pagination from '@/components/Pagination';
|
|
@@ -7,6 +7,7 @@ import { siteConfig } from '../../../../site.config';
|
|
|
7
7
|
import CoverImage from '@/components/CoverImage';
|
|
8
8
|
import Link from 'next/link';
|
|
9
9
|
import { t, resolveLocale } from '@/lib/i18n';
|
|
10
|
+
import { getPostUrl } from '@/lib/urls';
|
|
10
11
|
|
|
11
12
|
const PAGE_SIZE = siteConfig.pagination.series;
|
|
12
13
|
|
|
@@ -78,24 +79,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
|
|
78
79
|
const title = seriesData?.title || slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
79
80
|
const description = seriesData?.excerpt;
|
|
80
81
|
const coverImage = seriesData?.coverImage;
|
|
81
|
-
|
|
82
|
-
const explicitAuthors = getSeriesAuthors(slug);
|
|
83
|
-
let authors: string[];
|
|
84
|
-
if (explicitAuthors) {
|
|
85
|
-
authors = explicitAuthors;
|
|
86
|
-
} else if (allPosts.length > 0) {
|
|
87
|
-
const counts = new Map<string, number>();
|
|
88
|
-
for (const post of allPosts) {
|
|
89
|
-
for (const author of post.authors) {
|
|
90
|
-
counts.set(author, (counts.get(author) || 0) + 1);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
authors = [...counts.entries()]
|
|
94
|
-
.sort((a, b) => b[1] - a[1])
|
|
95
|
-
.map(([name]) => name);
|
|
96
|
-
} else {
|
|
97
|
-
authors = [];
|
|
98
|
-
}
|
|
82
|
+
const authors = resolveSeriesAuthors(slug, allPosts);
|
|
99
83
|
|
|
100
84
|
return (
|
|
101
85
|
<div className="layout-main">
|
|
@@ -125,6 +109,24 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
|
|
125
109
|
{description}
|
|
126
110
|
</p>
|
|
127
111
|
)}
|
|
112
|
+
{allPosts.length > 0 && (() => {
|
|
113
|
+
// Pick the first installment respecting sort order:
|
|
114
|
+
// date-desc (default) → oldest is last; date-asc/manual → oldest/first is [0]
|
|
115
|
+
const firstPost = (seriesData?.sort === 'date-asc' || seriesData?.sort === 'manual')
|
|
116
|
+
? allPosts[0]
|
|
117
|
+
: allPosts[allPosts.length - 1];
|
|
118
|
+
return (
|
|
119
|
+
<Link
|
|
120
|
+
href={getPostUrl(firstPost)}
|
|
121
|
+
className="mt-6 inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors no-underline"
|
|
122
|
+
>
|
|
123
|
+
{t('start_reading')}
|
|
124
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
125
|
+
<polyline points="9 18 15 12 9 6" />
|
|
126
|
+
</svg>
|
|
127
|
+
</Link>
|
|
128
|
+
);
|
|
129
|
+
})()}
|
|
128
130
|
{authors.length > 0 && (
|
|
129
131
|
<p className="mt-4 text-sm text-muted">
|
|
130
132
|
<span className="mr-1">{t('written_by')}</span>
|
package/src/app/series/page.tsx
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { getAllSeries, getSeriesData,
|
|
1
|
+
import { getAllSeries, getSeriesData, resolveSeriesAuthors } from '@/lib/markdown';
|
|
2
2
|
import Link from 'next/link';
|
|
3
3
|
import { siteConfig } from '../../../site.config';
|
|
4
4
|
import { Metadata } from 'next';
|
|
5
5
|
import CoverImage from '@/components/CoverImage';
|
|
6
|
-
import { t, resolveLocale } from '@/lib/i18n';
|
|
6
|
+
import { t, resolveLocale, tWith } from '@/lib/i18n';
|
|
7
7
|
import PageHeader from '@/components/PageHeader';
|
|
8
8
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
+
const allSeries = getAllSeries();
|
|
11
|
+
const count = Object.keys(allSeries).length;
|
|
12
|
+
return {
|
|
13
|
+
title: `${t('series')} | ${resolveLocale(siteConfig.title)}`,
|
|
14
|
+
description: tWith('series_subtitle', { count }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
13
17
|
|
|
14
18
|
export default function SeriesIndexPage() {
|
|
15
19
|
const allSeries = getAllSeries();
|
|
@@ -26,7 +30,7 @@ export default function SeriesIndexPage() {
|
|
|
26
30
|
return (
|
|
27
31
|
<div className="layout-main">
|
|
28
32
|
<PageHeader
|
|
29
|
-
titleKey="
|
|
33
|
+
titleKey="series"
|
|
30
34
|
subtitleKey="series_subtitle"
|
|
31
35
|
subtitleOneKey="series_subtitle_one"
|
|
32
36
|
count={totalSeries}
|
|
@@ -38,26 +42,8 @@ export default function SeriesIndexPage() {
|
|
|
38
42
|
const posts = allSeries[slug];
|
|
39
43
|
const seriesData = getSeriesData(slug);
|
|
40
44
|
const title = seriesData?.title || slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
41
|
-
const description = seriesData?.excerpt ||
|
|
42
|
-
|
|
43
|
-
// Resolve authors: explicit series authors, then aggregate from posts
|
|
44
|
-
const explicitAuthors = getSeriesAuthors(slug);
|
|
45
|
-
let authors: string[];
|
|
46
|
-
if (explicitAuthors) {
|
|
47
|
-
authors = explicitAuthors;
|
|
48
|
-
} else if (posts.length > 0) {
|
|
49
|
-
const counts = new Map<string, number>();
|
|
50
|
-
for (const post of posts) {
|
|
51
|
-
for (const author of post.authors) {
|
|
52
|
-
counts.set(author, (counts.get(author) || 0) + 1);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
authors = [...counts.entries()]
|
|
56
|
-
.sort((a, b) => b[1] - a[1])
|
|
57
|
-
.map(([name]) => name);
|
|
58
|
-
} else {
|
|
59
|
-
authors = [];
|
|
60
|
-
}
|
|
45
|
+
const description = seriesData?.excerpt || t('series_default_excerpt');
|
|
46
|
+
const authors = resolveSeriesAuthors(slug, posts);
|
|
61
47
|
|
|
62
48
|
return (
|
|
63
49
|
<Link key={slug} href={`/series/${slug}`} className="group block no-underline">
|
|
@@ -72,7 +58,7 @@ export default function SeriesIndexPage() {
|
|
|
72
58
|
</div>
|
|
73
59
|
<div className="p-8">
|
|
74
60
|
<span className="badge-accent mb-4 inline-block">
|
|
75
|
-
{posts.length} {t('
|
|
61
|
+
{posts.length} {t('parts')}
|
|
76
62
|
</span>
|
|
77
63
|
<h2 className="mb-3 font-serif text-2xl font-bold text-heading group-hover:text-accent transition-colors">
|
|
78
64
|
{title}
|
package/src/app/sitemap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MetadataRoute } from 'next';
|
|
2
2
|
import { getAllPosts, getAllPages, getAllBooks, getAllFlows } from '@/lib/markdown';
|
|
3
3
|
import { siteConfig } from '../../site.config';
|
|
4
|
+
import { getPostUrl } from '@/lib/urls';
|
|
4
5
|
|
|
5
6
|
export const dynamic = 'force-static';
|
|
6
7
|
|
|
@@ -12,7 +13,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|
|
12
13
|
const baseUrl = siteConfig.baseUrl;
|
|
13
14
|
|
|
14
15
|
const postUrls = posts.map((post) => ({
|
|
15
|
-
url: `${baseUrl}
|
|
16
|
+
url: `${baseUrl}${getPostUrl(post)}`,
|
|
16
17
|
lastModified: post.date,
|
|
17
18
|
changeFrequency: 'monthly' as const,
|
|
18
19
|
priority: 0.7,
|
|
@@ -50,7 +50,7 @@ export default async function TagPage({
|
|
|
50
50
|
<TagSidebar key={decodedTag} tags={allTags} activeTag={decodedTag} />
|
|
51
51
|
|
|
52
52
|
<div className="flex-1 min-w-0">
|
|
53
|
-
<TagPageHeader tag={decodedTag} />
|
|
53
|
+
<TagPageHeader tag={decodedTag} postCount={posts.length} flowCount={flows.length} />
|
|
54
54
|
<TagContentTabs posts={posts} flows={flows} />
|
|
55
55
|
</div>
|
|
56
56
|
</div>
|
package/src/app/tags/page.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import TagsIndexClient from '@/components/TagsIndexClient';
|
|
|
7
7
|
|
|
8
8
|
export const metadata: Metadata = {
|
|
9
9
|
title: `${t('tags')} | ${resolveLocale(siteConfig.title)}`,
|
|
10
|
-
description: '
|
|
10
|
+
description: t('tags_description'),
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export default function TagsPage() {
|
|
@@ -4,46 +4,44 @@ import Script from 'next/script';
|
|
|
4
4
|
import { siteConfig } from '../../site.config';
|
|
5
5
|
|
|
6
6
|
export default function Analytics() {
|
|
7
|
-
const {
|
|
7
|
+
const { providers, umami, plausible, google } = siteConfig.analytics;
|
|
8
8
|
|
|
9
|
-
if (
|
|
10
|
-
return (
|
|
11
|
-
<Script
|
|
12
|
-
src={umami.src}
|
|
13
|
-
data-website-id={umami.websiteId}
|
|
14
|
-
strategy="afterInteractive"
|
|
15
|
-
/>
|
|
16
|
-
);
|
|
17
|
-
}
|
|
9
|
+
if (!providers || providers.length === 0) return null;
|
|
18
10
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
src={plausible.src}
|
|
23
|
-
data-domain={plausible.domain}
|
|
24
|
-
strategy="afterInteractive"
|
|
25
|
-
/>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (provider === 'google' && google.measurementId) {
|
|
30
|
-
return (
|
|
31
|
-
<>
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
{providers.includes('umami') && umami.websiteId && (
|
|
32
14
|
<Script
|
|
33
|
-
src={
|
|
15
|
+
src={umami.src}
|
|
16
|
+
data-website-id={umami.websiteId}
|
|
34
17
|
strategy="afterInteractive"
|
|
35
18
|
/>
|
|
36
|
-
|
|
37
|
-
{`
|
|
38
|
-
window.dataLayer = window.dataLayer || [];
|
|
39
|
-
function gtag(){dataLayer.push(arguments);}
|
|
40
|
-
gtag('js', new Date());
|
|
41
|
-
gtag('config', '${google.measurementId}');
|
|
42
|
-
`}
|
|
43
|
-
</Script>
|
|
44
|
-
</>
|
|
45
|
-
);
|
|
46
|
-
}
|
|
19
|
+
)}
|
|
47
20
|
|
|
48
|
-
|
|
21
|
+
{providers.includes('plausible') && plausible.domain && (
|
|
22
|
+
<Script
|
|
23
|
+
src={plausible.src}
|
|
24
|
+
data-domain={plausible.domain}
|
|
25
|
+
strategy="afterInteractive"
|
|
26
|
+
/>
|
|
27
|
+
)}
|
|
28
|
+
|
|
29
|
+
{providers.includes('google') && google.measurementId && (
|
|
30
|
+
<>
|
|
31
|
+
<Script
|
|
32
|
+
src={`https://www.googletagmanager.com/gtag/js?id=${google.measurementId}`}
|
|
33
|
+
strategy="afterInteractive"
|
|
34
|
+
/>
|
|
35
|
+
<Script id="google-analytics" strategy="afterInteractive">
|
|
36
|
+
{`
|
|
37
|
+
window.dataLayer = window.dataLayer || [];
|
|
38
|
+
function gtag(){dataLayer.push(arguments);}
|
|
39
|
+
gtag('js', new Date());
|
|
40
|
+
gtag('config', '${google.measurementId}');
|
|
41
|
+
`}
|
|
42
|
+
</Script>
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
49
47
|
}
|
|
@@ -1,30 +1,51 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
|
+
import ExportedImage from 'next-image-export-optimizer';
|
|
2
3
|
import { getAuthorSlug } from '@/lib/markdown';
|
|
3
4
|
import { siteConfig } from '../../site.config';
|
|
4
5
|
import { t } from '@/lib/i18n';
|
|
5
6
|
|
|
7
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
8
|
+
const isExternal = (src: string) => src.startsWith('http') || src.startsWith('//');
|
|
9
|
+
|
|
6
10
|
export default function AuthorCard({ authors }: { authors: string[] }) {
|
|
7
11
|
if (!authors || authors.length === 0) return null;
|
|
8
12
|
|
|
9
13
|
return (
|
|
10
|
-
<div className="mt-12
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
<div className="mt-12 flex flex-col gap-4">
|
|
15
|
+
{authors.map((author) => {
|
|
16
|
+
const slug = getAuthorSlug(author);
|
|
17
|
+
const profile = siteConfig.authors?.[author];
|
|
18
|
+
const hasSocial = profile?.social && profile.social.length > 0;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
key={author}
|
|
23
|
+
className="flex flex-col sm:flex-row sm:items-center gap-5 rounded-2xl border border-muted/15 bg-muted/[0.04] px-6 py-5 transition-colors hover:bg-muted/[0.08]"
|
|
24
|
+
>
|
|
25
|
+
{/* Left — avatar + author info */}
|
|
26
|
+
<div className="flex items-start gap-4 flex-1 min-w-0">
|
|
27
|
+
{profile?.avatar ? (
|
|
28
|
+
<ExportedImage
|
|
29
|
+
src={profile.avatar}
|
|
30
|
+
alt={author}
|
|
31
|
+
width={56}
|
|
32
|
+
height={56}
|
|
33
|
+
className="w-14 h-14 rounded-full object-cover flex-shrink-0 ring-2 ring-muted/20"
|
|
34
|
+
unoptimized={isDev || isExternal(profile.avatar)}
|
|
35
|
+
/>
|
|
36
|
+
) : (
|
|
37
|
+
<div className="w-14 h-14 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-2xl select-none">
|
|
38
|
+
{author.charAt(0).toUpperCase()}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
15
41
|
|
|
16
|
-
return (
|
|
17
|
-
<div key={author} className="flex items-start gap-4">
|
|
18
|
-
<div className="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-lg select-none">
|
|
19
|
-
{author.charAt(0).toUpperCase()}
|
|
20
|
-
</div>
|
|
21
42
|
<div className="min-w-0">
|
|
22
43
|
<p className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-1">
|
|
23
44
|
{t('written_by')}
|
|
24
45
|
</p>
|
|
25
46
|
<Link
|
|
26
47
|
href={`/authors/${slug}`}
|
|
27
|
-
className="font-serif font-semibold text-heading hover:text-accent transition-colors no-underline"
|
|
48
|
+
className="font-serif font-semibold text-lg text-heading hover:text-accent transition-colors no-underline"
|
|
28
49
|
>
|
|
29
50
|
{author}
|
|
30
51
|
</Link>
|
|
@@ -35,9 +56,30 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
|
|
|
35
56
|
)}
|
|
36
57
|
</div>
|
|
37
58
|
</div>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
|
|
60
|
+
{/* Right — social images (e.g. QR codes) */}
|
|
61
|
+
{hasSocial && (
|
|
62
|
+
<div className="flex justify-center gap-5 flex-shrink-0 border-t border-muted/15 pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-6 sm:justify-start">
|
|
63
|
+
{profile.social!.map((item, index) => (
|
|
64
|
+
<figure key={index} className="flex flex-col items-center gap-1.5">
|
|
65
|
+
<ExportedImage
|
|
66
|
+
src={item.image}
|
|
67
|
+
alt={item.description}
|
|
68
|
+
width={72}
|
|
69
|
+
height={72}
|
|
70
|
+
className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
|
|
71
|
+
unoptimized={isDev || isExternal(item.image)}
|
|
72
|
+
/>
|
|
73
|
+
<figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
|
|
74
|
+
{item.description}
|
|
75
|
+
</figcaption>
|
|
76
|
+
</figure>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
41
83
|
</div>
|
|
42
84
|
);
|
|
43
85
|
}
|
|
@@ -4,6 +4,8 @@ import { useState } from 'react';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { BookTocItem, BookChapterEntry } from '@/lib/markdown';
|
|
6
6
|
import { useLanguage } from './LanguageProvider';
|
|
7
|
+
import PrevNextNav from './PrevNextNav';
|
|
8
|
+
import { getBookChapterUrl } from '@/lib/urls';
|
|
7
9
|
|
|
8
10
|
interface BookMobileNavProps {
|
|
9
11
|
bookSlug: string;
|
|
@@ -48,39 +50,11 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
|
|
|
48
50
|
</div>
|
|
49
51
|
|
|
50
52
|
{/* Prev / Next */}
|
|
51
|
-
<div className="
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
>
|
|
57
|
-
<svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
58
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
59
|
-
</svg>
|
|
60
|
-
<div className="min-w-0">
|
|
61
|
-
<span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('prev')}</span>
|
|
62
|
-
<span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{prevChapter.title}</span>
|
|
63
|
-
</div>
|
|
64
|
-
</Link>
|
|
65
|
-
) : (
|
|
66
|
-
<div className="flex-1" />
|
|
67
|
-
)}
|
|
68
|
-
{nextChapter ? (
|
|
69
|
-
<Link
|
|
70
|
-
href={`/books/${bookSlug}/${nextChapter.id}`}
|
|
71
|
-
className="flex-1 flex items-center justify-end gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group text-right"
|
|
72
|
-
>
|
|
73
|
-
<div className="min-w-0">
|
|
74
|
-
<span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('next')}</span>
|
|
75
|
-
<span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{nextChapter.title}</span>
|
|
76
|
-
</div>
|
|
77
|
-
<svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
78
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
79
|
-
</svg>
|
|
80
|
-
</Link>
|
|
81
|
-
) : (
|
|
82
|
-
<div className="flex-1" />
|
|
83
|
-
)}
|
|
53
|
+
<div className="mb-3">
|
|
54
|
+
<PrevNextNav
|
|
55
|
+
prev={prevChapter ? { href: getBookChapterUrl(bookSlug, prevChapter.id), title: prevChapter.title } : null}
|
|
56
|
+
next={nextChapter ? { href: getBookChapterUrl(bookSlug, nextChapter.id), title: nextChapter.title } : null}
|
|
57
|
+
/>
|
|
84
58
|
</div>
|
|
85
59
|
|
|
86
60
|
{/* Toggle to expand full chapter list */}
|
|
@@ -125,7 +99,7 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
|
|
|
125
99
|
</div>
|
|
126
100
|
) : (
|
|
127
101
|
<Link
|
|
128
|
-
href={
|
|
102
|
+
href={getBookChapterUrl(bookSlug, ch.id)}
|
|
129
103
|
className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
|
|
130
104
|
isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
|
|
131
105
|
}`}
|