@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
|
@@ -6,6 +6,8 @@ import CoverImage from '@/components/CoverImage';
|
|
|
6
6
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
7
7
|
import Link from 'next/link';
|
|
8
8
|
import { t, resolveLocale } from '@/lib/i18n';
|
|
9
|
+
import { buildBookJsonLd, serializeJsonLd } from '@/lib/json-ld';
|
|
10
|
+
import { getBookUrl } from '@/lib/urls';
|
|
9
11
|
|
|
10
12
|
export async function generateStaticParams() {
|
|
11
13
|
const books = getAllBooks();
|
|
@@ -58,8 +60,19 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
|
|
|
58
60
|
|
|
59
61
|
const firstChapter = book.chapters.length > 0 ? book.chapters[0] : null;
|
|
60
62
|
|
|
63
|
+
const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
64
|
+
const jsonLd = buildBookJsonLd({
|
|
65
|
+
book,
|
|
66
|
+
bookUrl: `${siteUrl}${getBookUrl(slug)}`,
|
|
67
|
+
siteTitle: resolveLocale(siteConfig.title),
|
|
68
|
+
siteUrl,
|
|
69
|
+
defaultOgImage: siteConfig.ogImage,
|
|
70
|
+
});
|
|
71
|
+
|
|
61
72
|
return (
|
|
62
|
-
|
|
73
|
+
<>
|
|
74
|
+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }} />
|
|
75
|
+
<div className="layout-main">
|
|
63
76
|
<header className="mb-16">
|
|
64
77
|
{/* Cover image */}
|
|
65
78
|
{book.coverImage && (
|
|
@@ -170,6 +183,7 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
|
|
|
170
183
|
<MarkdownRenderer content={book.content} slug={`books/${book.slug}`} />
|
|
171
184
|
</section>
|
|
172
185
|
)}
|
|
173
|
-
|
|
186
|
+
</div>
|
|
187
|
+
</>
|
|
174
188
|
);
|
|
175
189
|
}
|
package/src/app/books/page.tsx
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { getAllBooks } from '@/lib/markdown';
|
|
2
|
+
import { getBookUrl } from '@/lib/urls';
|
|
2
3
|
import Link from 'next/link';
|
|
3
4
|
import { siteConfig } from '../../../site.config';
|
|
4
5
|
import { Metadata } from 'next';
|
|
5
6
|
import CoverImage from '@/components/CoverImage';
|
|
6
|
-
import { t, resolveLocale } from '@/lib/i18n';
|
|
7
|
+
import { t, resolveLocale, tWith } from '@/lib/i18n';
|
|
7
8
|
import PageHeader from '@/components/PageHeader';
|
|
8
9
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
10
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
11
|
+
const books = getAllBooks();
|
|
12
|
+
return {
|
|
13
|
+
title: `${t('books')} | ${resolveLocale(siteConfig.title)}`,
|
|
14
|
+
description: books.length === 1 ? t('books_subtitle_one') : tWith('books_subtitle', { count: books.length }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
13
17
|
|
|
14
18
|
export default function BooksPage() {
|
|
15
19
|
const books = getAllBooks();
|
|
@@ -18,15 +22,15 @@ export default function BooksPage() {
|
|
|
18
22
|
<div className="layout-main">
|
|
19
23
|
<PageHeader
|
|
20
24
|
titleKey="books"
|
|
21
|
-
subtitleKey="
|
|
22
|
-
subtitleOneKey="
|
|
25
|
+
subtitleKey="books_subtitle"
|
|
26
|
+
subtitleOneKey="books_subtitle_one"
|
|
23
27
|
count={books.length}
|
|
24
28
|
subtitleParams={{ count: books.length }}
|
|
25
29
|
/>
|
|
26
30
|
|
|
27
31
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
28
32
|
{books.map(book => (
|
|
29
|
-
<Link key={book.slug} href={
|
|
33
|
+
<Link key={book.slug} href={getBookUrl(book.slug)} className="group block no-underline">
|
|
30
34
|
<div className="card-base h-full group flex flex-col p-0 overflow-hidden">
|
|
31
35
|
<div className="relative h-48 w-full overflow-hidden bg-muted/10">
|
|
32
36
|
<CoverImage
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { siteConfig } from '../../../site.config';
|
|
2
|
+
import { resolveLocale } from '@/lib/i18n';
|
|
3
|
+
import { getFeedItems } from '@/lib/feed-utils';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-static';
|
|
6
|
+
|
|
7
|
+
const escapeXml = (v: string) =>
|
|
8
|
+
v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
10
|
+
|
|
11
|
+
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
12
|
+
|
|
13
|
+
export async function GET() {
|
|
14
|
+
const { format, content: contentMode } = siteConfig.feed;
|
|
15
|
+
if (format === 'rss') {
|
|
16
|
+
return new Response('Not Found', { status: 404 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
20
|
+
const items = getFeedItems();
|
|
21
|
+
const useFullContent = contentMode === 'full';
|
|
22
|
+
const feedUpdated = items[0]?.date.toISOString() ?? new Date().toISOString();
|
|
23
|
+
|
|
24
|
+
const entriesXml = items
|
|
25
|
+
.map((item) => {
|
|
26
|
+
const contentXml = useFullContent
|
|
27
|
+
? `<content type="html"><![CDATA[${escapeCdata(item.content)}]]></content>\n <summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`
|
|
28
|
+
: `<summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`;
|
|
29
|
+
const authorsXml = item.authors?.map((a) => `<author><name>${escapeXml(a)}</name></author>`).join('') ?? '';
|
|
30
|
+
const categoriesXml = item.tags.map((tag) => `<category term="${escapeXml(tag)}" />`).join('');
|
|
31
|
+
return `
|
|
32
|
+
<entry>
|
|
33
|
+
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
34
|
+
<link href="${escapeXml(item.url)}" />
|
|
35
|
+
<id>${escapeXml(item.url)}</id>
|
|
36
|
+
<published>${item.date.toISOString()}</published>
|
|
37
|
+
<updated>${item.date.toISOString()}</updated>
|
|
38
|
+
${contentXml}
|
|
39
|
+
${authorsXml}
|
|
40
|
+
${categoriesXml}
|
|
41
|
+
</entry>`;
|
|
42
|
+
})
|
|
43
|
+
.join('');
|
|
44
|
+
|
|
45
|
+
const atomXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
46
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
47
|
+
<title><![CDATA[${escapeCdata(resolveLocale(siteConfig.title))}]]></title>
|
|
48
|
+
<link href="${escapeXml(baseUrl)}" />
|
|
49
|
+
<link href="${escapeXml(baseUrl)}/feed.atom" rel="self" type="application/atom+xml" />
|
|
50
|
+
<id>${escapeXml(baseUrl)}/feed.atom</id>
|
|
51
|
+
<updated>${feedUpdated}</updated>
|
|
52
|
+
<subtitle><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></subtitle>
|
|
53
|
+
${entriesXml}
|
|
54
|
+
</feed>`;
|
|
55
|
+
|
|
56
|
+
return new Response(atomXml, {
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'application/atom+xml; charset=utf-8',
|
|
59
|
+
'Cache-Control': 'public, max-age=3600',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -1,44 +1,69 @@
|
|
|
1
|
-
import { getAllPosts } from '@/lib/markdown';
|
|
2
1
|
import { siteConfig } from '../../../site.config';
|
|
3
2
|
import { resolveLocale } from '@/lib/i18n';
|
|
3
|
+
import { getFeedItems } from '@/lib/feed-utils';
|
|
4
4
|
|
|
5
5
|
export const dynamic = 'force-static';
|
|
6
6
|
|
|
7
|
+
const escapeXml = (v: string) =>
|
|
8
|
+
v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
10
|
+
|
|
11
|
+
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
12
|
+
|
|
7
13
|
export async function GET() {
|
|
8
|
-
const
|
|
9
|
-
|
|
14
|
+
const { format, content: contentMode } = siteConfig.feed;
|
|
15
|
+
if (format === 'atom') {
|
|
16
|
+
return new Response('Not Found', { status: 404 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
20
|
+
const items = getFeedItems();
|
|
21
|
+
const useFullContent = contentMode === 'full';
|
|
22
|
+
const contentNs = useFullContent ? ' xmlns:content="http://purl.org/rss/modules/content/"' : '';
|
|
23
|
+
const siteTitle = resolveLocale(siteConfig.title);
|
|
24
|
+
const lastBuildDate = items[0]?.date.toUTCString() ?? new Date().toUTCString();
|
|
25
|
+
|
|
26
|
+
const imageXml = siteConfig.ogImage
|
|
27
|
+
? `\n <image>\n <url>${escapeXml(baseUrl + siteConfig.ogImage)}</url>\n <title>${escapeXml(siteTitle)}</title>\n <link>${escapeXml(baseUrl)}</link>\n </image>`
|
|
28
|
+
: '';
|
|
10
29
|
|
|
11
|
-
const rssItemsXml =
|
|
12
|
-
.map((
|
|
13
|
-
const
|
|
30
|
+
const rssItemsXml = items
|
|
31
|
+
.map((item) => {
|
|
32
|
+
const fullContentXml = useFullContent
|
|
33
|
+
? `\n <content:encoded><![CDATA[${escapeCdata(item.content)}]]></content:encoded>`
|
|
34
|
+
: '';
|
|
35
|
+
const authorsXml = item.authors?.length
|
|
36
|
+
? item.authors.map((a) => `\n <dc:creator><![CDATA[${escapeCdata(a)}]]></dc:creator>`).join('')
|
|
37
|
+
: '';
|
|
14
38
|
return `
|
|
15
39
|
<item>
|
|
16
|
-
<title><![CDATA[${
|
|
17
|
-
<link>${url}</link>
|
|
18
|
-
<guid>${url}</guid>
|
|
19
|
-
<pubDate>${
|
|
20
|
-
<description><![CDATA[${
|
|
21
|
-
${
|
|
40
|
+
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
41
|
+
<link>${escapeXml(item.url)}</link>
|
|
42
|
+
<guid isPermaLink="true">${escapeXml(item.url)}</guid>
|
|
43
|
+
<pubDate>${item.date.toUTCString()}</pubDate>
|
|
44
|
+
<description><![CDATA[${escapeCdata(item.excerpt)}]]></description>${fullContentXml}${authorsXml}
|
|
45
|
+
${item.tags.map((tag) => `<category><![CDATA[${escapeCdata(tag)}]]></category>`).join('')}
|
|
22
46
|
</item>`;
|
|
23
47
|
})
|
|
24
48
|
.join('');
|
|
25
49
|
|
|
26
50
|
const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
27
|
-
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
51
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"${contentNs}>
|
|
28
52
|
<channel>
|
|
29
|
-
<title><![CDATA[${
|
|
30
|
-
<link>${baseUrl}</link>
|
|
31
|
-
<description><![CDATA[${resolveLocale(siteConfig.description)}]]></description>
|
|
32
|
-
<language
|
|
33
|
-
<lastBuildDate>${
|
|
34
|
-
<atom:link href="${baseUrl}/feed.xml" rel="self" type="application/rss+xml"
|
|
53
|
+
<title><![CDATA[${escapeCdata(siteTitle)}]]></title>
|
|
54
|
+
<link>${escapeXml(baseUrl)}</link>
|
|
55
|
+
<description><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></description>
|
|
56
|
+
<language>${siteConfig.i18n.defaultLocale}</language>
|
|
57
|
+
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
58
|
+
<atom:link href="${escapeXml(baseUrl)}/feed.xml" rel="self" type="application/rss+xml" />${imageXml}
|
|
35
59
|
${rssItemsXml}
|
|
36
60
|
</channel>
|
|
37
61
|
</rss>`;
|
|
38
62
|
|
|
39
63
|
return new Response(rssXml, {
|
|
40
64
|
headers: {
|
|
41
|
-
'Content-Type': 'application/xml',
|
|
65
|
+
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
66
|
+
'Cache-Control': 'public, max-age=3600',
|
|
42
67
|
},
|
|
43
68
|
});
|
|
44
69
|
}
|
|
@@ -4,6 +4,7 @@ import { Metadata } from 'next';
|
|
|
4
4
|
import { notFound } from 'next/navigation';
|
|
5
5
|
import { t, resolveLocale } from '@/lib/i18n';
|
|
6
6
|
import FlowCalendarSidebar from '@/components/FlowCalendarSidebar';
|
|
7
|
+
import Tag from '@/components/Tag';
|
|
7
8
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
8
9
|
import Backlinks from '@/components/Backlinks';
|
|
9
10
|
import ShareBar from '@/components/ShareBar';
|
|
@@ -85,11 +86,18 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
|
|
|
85
86
|
{/* Header */}
|
|
86
87
|
<header className="mb-8">
|
|
87
88
|
<time className="text-base font-mono text-accent" data-pagefind-meta="date[content]">{flow.date}</time>
|
|
89
|
+
{flow.tags.length > 0 && (
|
|
90
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
91
|
+
{flow.tags.map(tag => (
|
|
92
|
+
<Tag key={tag} tag={tag} variant="compact" />
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
88
96
|
</header>
|
|
89
97
|
|
|
90
98
|
{/* Content */}
|
|
91
99
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
|
92
|
-
<MarkdownRenderer content={flow.content} slugRegistry={slugRegistry} />
|
|
100
|
+
<MarkdownRenderer content={flow.content} slug={`flows/${year}/${month}/${day}`} slugRegistry={slugRegistry} />
|
|
93
101
|
</div>
|
|
94
102
|
|
|
95
103
|
<Backlinks backlinks={backlinks} />
|
|
@@ -44,12 +44,14 @@ export default async function FlowsPaginatedPage({ params }: { params: Promise<{
|
|
|
44
44
|
const start = (page - 1) * PAGE_SIZE;
|
|
45
45
|
const end = start + PAGE_SIZE;
|
|
46
46
|
const flows = allFlows.slice(start, end);
|
|
47
|
+
const allFlowItems = allFlows.map(({ slug, date, title, excerpt, tags }) => ({ slug, date, title, excerpt, tags }));
|
|
47
48
|
|
|
48
49
|
return (
|
|
49
50
|
<div className="layout-main">
|
|
50
51
|
<FlowHubTabs subtitle={tWith('page_of_total', { page, total: totalPages })} />
|
|
51
52
|
<FlowContent
|
|
52
53
|
flows={flows}
|
|
54
|
+
allFlows={allFlowItems}
|
|
53
55
|
entryDates={entryDates}
|
|
54
56
|
tags={tags}
|
|
55
57
|
pagination={{ currentPage: page, totalPages, basePath: '/flows' }}
|
package/src/app/flows/page.tsx
CHANGED
|
@@ -20,12 +20,16 @@ export default function FlowsPage() {
|
|
|
20
20
|
const flows = allFlows.slice(0, PAGE_SIZE);
|
|
21
21
|
const entryDates = allFlows.map(f => f.date);
|
|
22
22
|
const tags = getFlowTags();
|
|
23
|
+
const allFlowItems = totalPages > 1
|
|
24
|
+
? allFlows.map(({ slug, date, title, excerpt, tags }) => ({ slug, date, title, excerpt, tags }))
|
|
25
|
+
: undefined;
|
|
23
26
|
|
|
24
27
|
return (
|
|
25
28
|
<div className="layout-main">
|
|
26
29
|
<FlowHubTabs subtitle={tWith('flow_subtitle', { count: allFlows.length })} />
|
|
27
30
|
<FlowContent
|
|
28
31
|
flows={flows}
|
|
32
|
+
allFlows={allFlowItems}
|
|
29
33
|
entryDates={entryDates}
|
|
30
34
|
tags={tags}
|
|
31
35
|
pagination={totalPages > 1 ? { currentPage: 1, totalPages, basePath: '/flows' } : undefined}
|
package/src/app/globals.css
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
|
-
@import "katex/dist/katex.min.css";
|
|
3
2
|
@plugin "@tailwindcss/typography";
|
|
4
3
|
|
|
4
|
+
html {
|
|
5
|
+
scroll-behavior: smooth;
|
|
6
|
+
scroll-padding-top: 4rem; /* offset for fixed navbar (h-16 = 64px) */
|
|
7
|
+
overflow-x: hidden;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
:root {
|
|
6
11
|
/* Warm/Paper-like background for "Elegant" feel */
|
|
7
12
|
--background: #fafaf9; /* Stone 50 */
|
|
@@ -14,7 +19,7 @@
|
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
.dark {
|
|
17
|
-
--background: #
|
|
22
|
+
--background: #292524; /* Stone 800 */
|
|
18
23
|
--foreground: #f5f5f4; /* Stone 100 */
|
|
19
24
|
--heading: #fafaf9; /* Stone 50 */
|
|
20
25
|
--accent: #34d399; /* Emerald 400 */
|
|
@@ -436,6 +441,7 @@ pre[class*="language-"] {
|
|
|
436
441
|
body {
|
|
437
442
|
background-color: var(--background);
|
|
438
443
|
color: var(--foreground);
|
|
444
|
+
overflow-x: hidden;
|
|
439
445
|
/* Font smoothing for elegance */
|
|
440
446
|
-webkit-font-smoothing: antialiased;
|
|
441
447
|
-moz-osx-font-smoothing: grayscale;
|
|
@@ -444,4 +450,4 @@ body {
|
|
|
444
450
|
/* Ensure anchor links are not hidden behind the fixed navbar */
|
|
445
451
|
[id] {
|
|
446
452
|
scroll-margin-top: 5rem;
|
|
447
|
-
}
|
|
453
|
+
}
|
package/src/app/layout.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { LanguageProvider } from "@/components/LanguageProvider";
|
|
|
10
10
|
import { getAllSeries, getAllBooks, getSeriesData } from "@/lib/markdown";
|
|
11
11
|
import { resolveLocale } from "@/lib/i18n";
|
|
12
12
|
import "./globals.css";
|
|
13
|
+
import "katex/dist/katex.min.css";
|
|
13
14
|
|
|
14
15
|
const inter = localFont({
|
|
15
16
|
src: [
|
|
@@ -54,13 +55,33 @@ const siteTwitterHandle = (() => {
|
|
|
54
55
|
return m ? `@${m[1]}` : undefined;
|
|
55
56
|
})();
|
|
56
57
|
|
|
58
|
+
// Build icon metadata with explicit MIME type so browsers correctly handle
|
|
59
|
+
// all formats. Without this, .ico files served via the metadata API lack
|
|
60
|
+
// type="image/x-icon" and may be ignored in favour of app/favicon.ico.
|
|
61
|
+
const faviconSrc = siteConfig.logo?.favicon || "/icon.svg";
|
|
62
|
+
const ext = faviconSrc.split('.').pop()?.toLowerCase();
|
|
63
|
+
const faviconMeta =
|
|
64
|
+
ext === 'ico' ? [{ url: faviconSrc, type: 'image/x-icon', sizes: 'any' }] :
|
|
65
|
+
ext === 'png' ? [{ url: faviconSrc, type: 'image/png' }] :
|
|
66
|
+
ext === 'svg' ? [{ url: faviconSrc, type: 'image/svg+xml' }] :
|
|
67
|
+
faviconSrc;
|
|
68
|
+
|
|
69
|
+
const feedAlternates = (() => {
|
|
70
|
+
const { format } = siteConfig.feed;
|
|
71
|
+
const types: Record<string, string> = {};
|
|
72
|
+
if (format === 'rss' || format === 'both') types['application/rss+xml'] = '/feed.xml';
|
|
73
|
+
if (format === 'atom' || format === 'both') types['application/atom+xml'] = '/feed.atom';
|
|
74
|
+
return Object.keys(types).length > 0 ? { types } : undefined;
|
|
75
|
+
})();
|
|
76
|
+
|
|
57
77
|
export const metadata: Metadata = {
|
|
58
78
|
metadataBase: new URL(siteConfig.baseUrl),
|
|
59
79
|
title: resolveLocale(siteConfig.title),
|
|
60
80
|
description: resolveLocale(siteConfig.description),
|
|
61
81
|
icons: {
|
|
62
|
-
icon:
|
|
82
|
+
icon: faviconMeta,
|
|
63
83
|
},
|
|
84
|
+
...(feedAlternates && { alternates: feedAlternates }),
|
|
64
85
|
openGraph: {
|
|
65
86
|
siteName: resolveLocale(siteConfig.title),
|
|
66
87
|
locale: siteConfig.i18n.defaultLocale,
|
|
@@ -74,7 +74,7 @@ export default async function NotePage({ params }: { params: Promise<{ slug: str
|
|
|
74
74
|
breadcrumb={breadcrumb}
|
|
75
75
|
/>
|
|
76
76
|
)}
|
|
77
|
-
<article className="min-w-0 max-w-3xl mx-auto">
|
|
77
|
+
<article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
|
|
78
78
|
<header className="mb-8 border-b border-muted/10 pb-8">
|
|
79
79
|
{note.draft && (
|
|
80
80
|
<div className="mb-4">
|
|
@@ -98,7 +98,7 @@ export default async function NotePage({ params }: { params: Promise<{ slug: str
|
|
|
98
98
|
)}
|
|
99
99
|
</header>
|
|
100
100
|
|
|
101
|
-
<MarkdownRenderer content={note.content} slug={note.slug} slugRegistry={slugRegistry} />
|
|
101
|
+
<MarkdownRenderer content={note.content} slug={`notes/${note.slug}`} slugRegistry={slugRegistry} />
|
|
102
102
|
|
|
103
103
|
{/* Prev/Next navigation */}
|
|
104
104
|
<nav aria-label="Note navigation" className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-2 gap-4">
|
|
@@ -126,4 +126,3 @@ export default async function NotePage({ params }: { params: Promise<{ slug: str
|
|
|
126
126
|
</div>
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
|
-
|
package/src/app/page.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { getAllPosts, getAllFlows, getAllSeries, getFeaturedSeries, getSeriesData, getFeaturedPosts, getAllBooks, getFeaturedBooks, getRecentFlows } from '@/lib/markdown';
|
|
2
3
|
import { siteConfig } from '../../site.config';
|
|
3
4
|
import Hero from '@/components/Hero';
|
|
4
5
|
import CuratedSeriesSection, { SeriesItem } from '@/components/CuratedSeriesSection';
|
|
@@ -7,7 +8,8 @@ import SelectedBooksSection, { BookItem } from '@/components/SelectedBooksSectio
|
|
|
7
8
|
import LatestWritingSection from '@/components/LatestWritingSection';
|
|
8
9
|
import RecentNotesSection, { RecentNoteItem } from '@/components/RecentNotesSection';
|
|
9
10
|
import { Metadata } from 'next';
|
|
10
|
-
import { resolveLocale } from '@/lib/i18n';
|
|
11
|
+
import { t, resolveLocale } from '@/lib/i18n';
|
|
12
|
+
import { buildWebsiteJsonLd, serializeJsonLd } from '@/lib/json-ld';
|
|
11
13
|
|
|
12
14
|
export const metadata: Metadata = {
|
|
13
15
|
title: resolveLocale(siteConfig.title),
|
|
@@ -21,7 +23,7 @@ export const metadata: Metadata = {
|
|
|
21
23
|
images: [{ url: siteConfig.ogImage, width: 1200, height: 630 }],
|
|
22
24
|
},
|
|
23
25
|
twitter: {
|
|
24
|
-
card: '
|
|
26
|
+
card: 'summary_large_image',
|
|
25
27
|
title: resolveLocale(siteConfig.title),
|
|
26
28
|
description: resolveLocale(siteConfig.description),
|
|
27
29
|
},
|
|
@@ -70,7 +72,7 @@ export default function Home() {
|
|
|
70
72
|
return {
|
|
71
73
|
name,
|
|
72
74
|
title: seriesData?.title || name,
|
|
73
|
-
excerpt: seriesData?.excerpt ||
|
|
75
|
+
excerpt: seriesData?.excerpt || t('series_default_excerpt'),
|
|
74
76
|
coverImage: seriesData?.coverImage,
|
|
75
77
|
url: `/series/${slug}`,
|
|
76
78
|
postCount: seriesPosts.length,
|
|
@@ -103,14 +105,25 @@ export default function Home() {
|
|
|
103
105
|
? featuredPosts.map(p => ({
|
|
104
106
|
slug: p.slug,
|
|
105
107
|
title: p.title,
|
|
108
|
+
subtitle: p.subtitle,
|
|
106
109
|
excerpt: p.excerpt,
|
|
107
110
|
date: p.date,
|
|
108
111
|
category: p.category,
|
|
109
112
|
readingTime: p.readingTime,
|
|
110
113
|
coverImage: p.coverImage,
|
|
114
|
+
series: p.series,
|
|
115
|
+
pinned: p.pinned,
|
|
111
116
|
}))
|
|
112
117
|
: [];
|
|
113
118
|
|
|
119
|
+
// Stats for hero navigation chips
|
|
120
|
+
const heroPostCount = has('hero') && features?.posts?.enabled !== false
|
|
121
|
+
? (needsPosts ? allPosts : getAllPosts()).length
|
|
122
|
+
: undefined;
|
|
123
|
+
const heroSeriesCount = has('hero') && features?.series?.enabled !== false ? Object.keys(getAllSeries()).length : undefined;
|
|
124
|
+
const heroBookCount = has('hero') && features?.books?.enabled !== false ? getAllBooks().length : undefined;
|
|
125
|
+
const heroFlowCount = has('hero') && features?.flow?.enabled !== false ? getAllFlows().length : undefined;
|
|
126
|
+
|
|
114
127
|
const renderSection = (section: HomepageSection) => {
|
|
115
128
|
switch (section.id) {
|
|
116
129
|
case 'featured-series':
|
|
@@ -130,7 +143,6 @@ export default function Home() {
|
|
|
130
143
|
key="featured-books"
|
|
131
144
|
books={bookItems}
|
|
132
145
|
maxItems={section.maxItems ?? 4}
|
|
133
|
-
scrollThreshold={section.scrollThreshold ?? 2}
|
|
134
146
|
/>
|
|
135
147
|
);
|
|
136
148
|
case 'featured-posts':
|
|
@@ -140,7 +152,6 @@ export default function Home() {
|
|
|
140
152
|
key="featured-posts"
|
|
141
153
|
allFeatured={featuredItems}
|
|
142
154
|
maxItems={section.maxItems ?? 4}
|
|
143
|
-
scrollThreshold={section.scrollThreshold ?? 1}
|
|
144
155
|
/>
|
|
145
156
|
);
|
|
146
157
|
case 'latest-posts':
|
|
@@ -154,19 +165,86 @@ export default function Home() {
|
|
|
154
165
|
}
|
|
155
166
|
};
|
|
156
167
|
|
|
168
|
+
// Build content sections, pairing latest-posts + recent-flows into a two-column layout
|
|
169
|
+
const sectionsForContent = sections.filter(s => s.id !== 'hero');
|
|
170
|
+
const latestIdx = sectionsForContent.findIndex(s => s.id === 'latest-posts');
|
|
171
|
+
const flowsIdx = sectionsForContent.findIndex(s => s.id === 'recent-flows');
|
|
172
|
+
const pairLatestFlows = latestIdx >= 0 && flowsIdx >= 0;
|
|
173
|
+
|
|
174
|
+
// Show a divider after the two-column section when series follows it
|
|
175
|
+
const divideBeforeSeries = pairLatestFlows
|
|
176
|
+
&& has('featured-series')
|
|
177
|
+
&& features?.series?.enabled !== false
|
|
178
|
+
&& seriesItems.length > 0;
|
|
179
|
+
|
|
180
|
+
const renderList: ReactNode[] = [];
|
|
181
|
+
const skippedIds = new Set<string>();
|
|
182
|
+
|
|
183
|
+
for (const section of sectionsForContent) {
|
|
184
|
+
if (skippedIds.has(section.id)) continue;
|
|
185
|
+
|
|
186
|
+
if (pairLatestFlows && (section.id === 'latest-posts' || section.id === 'recent-flows')) {
|
|
187
|
+
skippedIds.add(section.id === 'latest-posts' ? 'recent-flows' : 'latest-posts');
|
|
188
|
+
const showLatest = features?.posts?.enabled !== false;
|
|
189
|
+
const showFlows = features?.flow?.enabled !== false && recentNoteItems.length > 0;
|
|
190
|
+
if (showLatest || showFlows) {
|
|
191
|
+
renderList.push(
|
|
192
|
+
<div key="latest-flows-combined" className={`grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 ${divideBeforeSeries ? 'mb-16' : 'mb-24'}`}>
|
|
193
|
+
{showLatest && (
|
|
194
|
+
<div className="lg:col-span-7">
|
|
195
|
+
<LatestWritingSection posts={posts} totalCount={allPosts.length} />
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
{showFlows && (
|
|
199
|
+
<div className="lg:col-span-5">
|
|
200
|
+
<RecentNotesSection notes={recentNoteItems} />
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
if (divideBeforeSeries) {
|
|
206
|
+
renderList.push(
|
|
207
|
+
<div key="series-divider" className="border-t border-muted/10 mb-16" />
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
renderList.push(renderSection(section));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const websiteJsonLd = buildWebsiteJsonLd({
|
|
217
|
+
siteTitle: resolveLocale(siteConfig.title),
|
|
218
|
+
siteUrl: siteConfig.baseUrl,
|
|
219
|
+
description: resolveLocale(siteConfig.description),
|
|
220
|
+
});
|
|
221
|
+
|
|
157
222
|
return (
|
|
223
|
+
<>
|
|
224
|
+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(websiteJsonLd) }} />
|
|
158
225
|
<div>
|
|
159
226
|
{has('hero') && (
|
|
160
227
|
<Hero
|
|
161
228
|
tagline={siteConfig.hero.tagline}
|
|
162
229
|
title={siteConfig.hero.title}
|
|
163
230
|
subtitle={siteConfig.hero.subtitle}
|
|
231
|
+
postCount={heroPostCount}
|
|
232
|
+
seriesCount={heroSeriesCount}
|
|
233
|
+
bookCount={heroBookCount}
|
|
234
|
+
flowCount={heroFlowCount}
|
|
235
|
+
featureNames={{
|
|
236
|
+
flow: features?.flow?.name,
|
|
237
|
+
posts: features?.posts?.name,
|
|
238
|
+
series: features?.series?.name,
|
|
239
|
+
books: features?.books?.name,
|
|
240
|
+
}}
|
|
164
241
|
/>
|
|
165
242
|
)}
|
|
166
243
|
|
|
167
|
-
<div className="layout-main pt-0 md:pt-0">
|
|
168
|
-
{
|
|
244
|
+
<div className="layout-main pt-0 md:pt-0 [&>*:last-child]:mb-0">
|
|
245
|
+
{renderList}
|
|
169
246
|
</div>
|
|
170
247
|
</div>
|
|
248
|
+
</>
|
|
171
249
|
);
|
|
172
250
|
}
|
|
@@ -5,6 +5,8 @@ import SimpleLayout from '@/layouts/SimpleLayout';
|
|
|
5
5
|
import { Metadata } from 'next';
|
|
6
6
|
import { siteConfig } from '../../../../site.config';
|
|
7
7
|
import { resolveLocale } from '@/lib/i18n';
|
|
8
|
+
import { getPostsBasePath, getPostUrl } from '@/lib/urls';
|
|
9
|
+
import { buildPostJsonLd, serializeJsonLd, resolveImageUrl } from '@/lib/json-ld';
|
|
8
10
|
|
|
9
11
|
function safeDecodeParam(param: string): string {
|
|
10
12
|
try {
|
|
@@ -29,9 +31,20 @@ function resolvePostFromParam(rawSlug: string) {
|
|
|
29
31
|
* This ensures fast page loads and SEO optimization.
|
|
30
32
|
*/
|
|
31
33
|
export async function generateStaticParams() {
|
|
34
|
+
if (getPostsBasePath() !== 'posts') return [{ slug: '_' }]; // Route disabled; custom path handles this
|
|
32
35
|
const posts = getAllPosts();
|
|
33
36
|
if (posts.length === 0) return [{ slug: '_' }];
|
|
34
|
-
|
|
37
|
+
// Work around Next dev static-param checks for percent-encoded Unicode paths
|
|
38
|
+
// under `output: "export"` by including encoded variants only in development.
|
|
39
|
+
// Production export keeps raw segment values.
|
|
40
|
+
const slugs = new Set<string>();
|
|
41
|
+
for (const post of posts) {
|
|
42
|
+
slugs.add(post.slug);
|
|
43
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
44
|
+
slugs.add(encodeURIComponent(post.slug));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return Array.from(slugs).map((slug) => ({ slug }));
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
export const dynamicParams = false;
|
|
@@ -46,9 +59,8 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
46
59
|
};
|
|
47
60
|
}
|
|
48
61
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
: siteConfig.ogImage;
|
|
62
|
+
const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
63
|
+
const ogImage = resolveImageUrl(post.coverImage, siteConfig.ogImage, siteUrl);
|
|
52
64
|
|
|
53
65
|
return {
|
|
54
66
|
title: `${post.title} | ${resolveLocale(siteConfig.title)}`,
|
|
@@ -94,8 +106,18 @@ export default async function PostPage({
|
|
|
94
106
|
// Determine layout based on frontmatter
|
|
95
107
|
const layout = post.layout || 'post';
|
|
96
108
|
|
|
109
|
+
const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
110
|
+
const jsonLd = buildPostJsonLd({
|
|
111
|
+
post,
|
|
112
|
+
postUrl: `${siteUrl}${getPostUrl(post)}`,
|
|
113
|
+
siteTitle: resolveLocale(siteConfig.title),
|
|
114
|
+
siteUrl,
|
|
115
|
+
defaultOgImage: siteConfig.ogImage,
|
|
116
|
+
});
|
|
117
|
+
const jsonLdScript = <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }} />;
|
|
118
|
+
|
|
97
119
|
if (layout === 'simple') {
|
|
98
|
-
return <SimpleLayout post={post}
|
|
120
|
+
return <>{jsonLdScript}<SimpleLayout post={post} /></>;
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
const relatedPosts = getRelatedPosts(slug);
|
|
@@ -111,6 +133,10 @@ export default async function PostPage({
|
|
|
111
133
|
seriesTitle = seriesData?.title;
|
|
112
134
|
}
|
|
113
135
|
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
{jsonLdScript}
|
|
139
|
+
<PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} prevPost={prev} nextPost={next} backlinks={backlinks} slugRegistry={slugRegistry} />
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
116
142
|
}
|