@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
|
@@ -3,9 +3,11 @@ import sizeOf from 'image-size';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import { Root, Element } from 'hast';
|
|
6
|
+
import { getCdnImageUrl } from './image-utils';
|
|
6
7
|
|
|
7
8
|
interface Options {
|
|
8
9
|
slug?: string;
|
|
10
|
+
cdnBaseUrl?: string;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export default function rehypeImageMetadata(options: Options) {
|
|
@@ -20,11 +22,21 @@ export default function rehypeImageMetadata(options: Options) {
|
|
|
20
22
|
let publicPath = '';
|
|
21
23
|
|
|
22
24
|
if (src.startsWith('./') && options.slug) {
|
|
23
|
-
// Relative path
|
|
25
|
+
// Relative path: slug is the full public-relative base path (e.g. posts/my-post, books/my-book, flows/2026/01/15)
|
|
24
26
|
const relative = src.substring(2);
|
|
25
27
|
// Use path.resolve to create absolute path without explicitly invoking process.cwd() in a way that triggers broad matching warnings
|
|
26
|
-
imagePath = path.resolve('public',
|
|
27
|
-
publicPath =
|
|
28
|
+
imagePath = path.resolve('public', options.slug, relative);
|
|
29
|
+
publicPath = `/${options.slug}/${relative}`;
|
|
30
|
+
} else if (
|
|
31
|
+
options.slug &&
|
|
32
|
+
!src.startsWith('/') &&
|
|
33
|
+
!src.startsWith('../') &&
|
|
34
|
+
!src.startsWith('data:') &&
|
|
35
|
+
!src.startsWith('#')
|
|
36
|
+
) {
|
|
37
|
+
// Bare relative path (e.g. assets/image.png): resolve against the content slug root.
|
|
38
|
+
imagePath = path.resolve('public', options.slug, src);
|
|
39
|
+
publicPath = `/${options.slug}/${src}`;
|
|
28
40
|
} else if (src.startsWith('/')) {
|
|
29
41
|
// Absolute path from public
|
|
30
42
|
// Remove leading slash for path.resolve
|
|
@@ -34,15 +46,19 @@ export default function rehypeImageMetadata(options: Options) {
|
|
|
34
46
|
return;
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
// Always resolve to the public path (and apply CDN prefix if configured).
|
|
50
|
+
// Path resolution must happen regardless of whether the file exists locally,
|
|
51
|
+
// so that images hosted only on CDN still get the correct URL.
|
|
52
|
+
node.properties.src = getCdnImageUrl(publicPath, options.cdnBaseUrl ?? '');
|
|
53
|
+
|
|
54
|
+
// Enrich with dimensions only when the file is available locally
|
|
37
55
|
try {
|
|
38
|
-
// Check if file exists before reading
|
|
39
56
|
if (imagePath && fs.existsSync(imagePath)) {
|
|
40
57
|
const buffer = fs.readFileSync(imagePath);
|
|
41
58
|
const dimensions = sizeOf(buffer);
|
|
42
59
|
if (dimensions) {
|
|
43
60
|
node.properties.width = dimensions.width;
|
|
44
61
|
node.properties.height = dimensions.height;
|
|
45
|
-
node.properties.src = publicPath;
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
} catch {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
export function scrollToHeading(e: React.MouseEvent<HTMLAnchorElement>, id: string): void {
|
|
4
|
+
const element = document.getElementById(id);
|
|
5
|
+
if (element) {
|
|
6
|
+
e.preventDefault();
|
|
7
|
+
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
|
8
|
+
window.scrollTo({ top: elementPosition - 80, behavior: 'smooth' });
|
|
9
|
+
history.pushState(null, '', `#${id}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/lib/shuffle.ts
CHANGED
|
@@ -9,3 +9,24 @@ export function shuffle<T>(array: T[]): T[] {
|
|
|
9
9
|
}
|
|
10
10
|
return shuffled;
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Deterministic Fisher-Yates shuffle using a seeded xorshift32 PRNG.
|
|
15
|
+
* Produces the same order for the same seed on both server and client,
|
|
16
|
+
* preventing hydration mismatches when the initial shuffle must be stable.
|
|
17
|
+
*/
|
|
18
|
+
export function shuffleSeeded<T>(array: T[], seed: number): T[] {
|
|
19
|
+
const shuffled = [...array];
|
|
20
|
+
let s = seed || 1;
|
|
21
|
+
const rand = () => {
|
|
22
|
+
s ^= s << 13;
|
|
23
|
+
s ^= s >> 17;
|
|
24
|
+
s ^= s << 5;
|
|
25
|
+
return (s >>> 0) / 0x100000000;
|
|
26
|
+
};
|
|
27
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
28
|
+
const j = Math.floor(rand() * (i + 1));
|
|
29
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
30
|
+
}
|
|
31
|
+
return shuffled;
|
|
32
|
+
}
|
package/src/lib/urls.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { siteConfig } from '../../site.config';
|
|
2
|
+
|
|
3
|
+
/** Strip leading and trailing slashes so path segments compose cleanly. */
|
|
4
|
+
function normalizeSegment(segment: string): string {
|
|
5
|
+
return segment.replace(/^\/+|\/+$/g, '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getPostsBasePath(): string {
|
|
9
|
+
return normalizeSegment(siteConfig.posts?.basePath ?? 'posts') || 'posts';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getSeriesCustomPaths(): Record<string, string> {
|
|
13
|
+
const raw = siteConfig.series?.customPaths ?? {};
|
|
14
|
+
return Object.fromEntries(
|
|
15
|
+
Object.entries(raw).map(([k, v]) => [k, normalizeSegment(v)])
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Returns the canonical URL path for a post, respecting series custom paths and posts basePath. */
|
|
20
|
+
export function getPostUrl(post: { slug: string; series?: string }): string {
|
|
21
|
+
if (post.series) {
|
|
22
|
+
const customPath = getSeriesCustomPaths()[post.series];
|
|
23
|
+
if (customPath) return `/${customPath}/${post.slug}`;
|
|
24
|
+
}
|
|
25
|
+
return `/${getPostsBasePath()}/${post.slug}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns the posts listing URL (page 1). */
|
|
29
|
+
export function getPostsListUrl(): string {
|
|
30
|
+
return `/${getPostsBasePath()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns a posts listing page URL (page 2+). */
|
|
34
|
+
export function getPostsPageUrl(page: number): string {
|
|
35
|
+
return `/${getPostsBasePath()}/page/${page}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Returns the canonical URL path for a book landing page. */
|
|
39
|
+
export function getBookUrl(slug: string): string {
|
|
40
|
+
return `/books/${slug}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Returns the canonical URL path for a book chapter. */
|
|
44
|
+
export function getBookChapterUrl(bookSlug: string, chapterSlug: string): string {
|
|
45
|
+
return `/books/${bookSlug}/${chapterSlug}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns the canonical URL path for a flow note. */
|
|
49
|
+
export function getFlowUrl(slug: string): string {
|
|
50
|
+
return `/flows/${slug}`;
|
|
51
|
+
}
|
|
@@ -40,12 +40,26 @@ describe("E2E: Navigation & Assets", () => {
|
|
|
40
40
|
expect(text).toContain("<urlset");
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test("feed.xml should
|
|
43
|
+
test("feed.xml should be a valid RSS feed", async () => {
|
|
44
44
|
if (!(await isServerRunning())) return;
|
|
45
45
|
|
|
46
46
|
const response = await fetch(`${BASE_URL}/feed.xml`);
|
|
47
47
|
expect(response.status).toBe(200);
|
|
48
|
+
expect(response.headers.get("content-type")).toContain("xml");
|
|
48
49
|
const text = await response.text();
|
|
49
50
|
expect(text).toContain("<rss");
|
|
51
|
+
expect(text).toContain("<channel>");
|
|
52
|
+
expect(text).toContain("<item>");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("feed.atom should be a valid Atom feed", async () => {
|
|
56
|
+
if (!(await isServerRunning())) return;
|
|
57
|
+
|
|
58
|
+
const response = await fetch(`${BASE_URL}/feed.atom`);
|
|
59
|
+
expect(response.status).toBe(200);
|
|
60
|
+
expect(response.headers.get("content-type")).toContain("xml");
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
expect(text).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
|
63
|
+
expect(text).toContain("<entry>");
|
|
50
64
|
});
|
|
51
65
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getAllBooks, getFeaturedBooks } from "../../src/lib/markdown";
|
|
3
|
+
|
|
4
|
+
describe("Integration: Books", () => {
|
|
5
|
+
test("getAllBooks returns an array", () => {
|
|
6
|
+
const books = getAllBooks();
|
|
7
|
+
expect(Array.isArray(books)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("getAllBooks entries have required fields", () => {
|
|
11
|
+
const books = getAllBooks();
|
|
12
|
+
books.forEach((book) => {
|
|
13
|
+
expect(typeof book.slug).toBe("string");
|
|
14
|
+
expect(book.slug.length).toBeGreaterThan(0);
|
|
15
|
+
expect(typeof book.title).toBe("string");
|
|
16
|
+
expect(book.title.length).toBeGreaterThan(0);
|
|
17
|
+
expect(typeof book.date).toBe("string");
|
|
18
|
+
expect(Array.isArray(book.authors)).toBe(true);
|
|
19
|
+
expect(Array.isArray(book.chapters)).toBe(true);
|
|
20
|
+
expect(typeof book.featured).toBe("boolean");
|
|
21
|
+
expect(typeof book.draft).toBe("boolean");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("getAllBooks is sorted newest first", () => {
|
|
26
|
+
const books = getAllBooks();
|
|
27
|
+
for (let i = 1; i < books.length; i++) {
|
|
28
|
+
expect(books[i - 1].date >= books[i].date).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("getFeaturedBooks returns only books with featured: true", () => {
|
|
33
|
+
const featured = getFeaturedBooks();
|
|
34
|
+
featured.forEach((book) => {
|
|
35
|
+
expect(book.featured).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("getFeaturedBooks is a subset of getAllBooks", () => {
|
|
40
|
+
const all = getAllBooks();
|
|
41
|
+
const featured = getFeaturedBooks();
|
|
42
|
+
expect(featured.length).toBeLessThanOrEqual(all.length);
|
|
43
|
+
const allSlugs = new Set(all.map((b) => b.slug));
|
|
44
|
+
featured.forEach((book) => {
|
|
45
|
+
expect(allSlugs.has(book.slug)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("getAllBooks excludes drafts in production", () => {
|
|
50
|
+
const prev = process.env.NODE_ENV;
|
|
51
|
+
process.env.NODE_ENV = "production";
|
|
52
|
+
try {
|
|
53
|
+
const books = getAllBooks();
|
|
54
|
+
books.forEach((book) => {
|
|
55
|
+
expect(book.draft).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
} finally {
|
|
58
|
+
process.env.NODE_ENV = prev;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getFeedItems } from "../../src/lib/feed-utils";
|
|
3
|
+
import { getAllPosts, getAllFlows } from "../../src/lib/markdown";
|
|
4
|
+
import { siteConfig } from "../../site.config";
|
|
5
|
+
|
|
6
|
+
describe("Integration: Feed Utils", () => {
|
|
7
|
+
test("getFeedItems returns an array", () => {
|
|
8
|
+
const items = getFeedItems();
|
|
9
|
+
expect(Array.isArray(items)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("feed items have required fields", () => {
|
|
13
|
+
const items = getFeedItems();
|
|
14
|
+
items.forEach((item) => {
|
|
15
|
+
expect(item.title).toBeDefined();
|
|
16
|
+
expect(typeof item.url).toBe("string");
|
|
17
|
+
expect(item.date).toBeInstanceOf(Date);
|
|
18
|
+
expect(typeof item.excerpt).toBe("string");
|
|
19
|
+
expect(typeof item.content).toBe("string");
|
|
20
|
+
expect(Array.isArray(item.tags)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("feed item URLs include the site baseUrl", () => {
|
|
25
|
+
const items = getFeedItems();
|
|
26
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, "");
|
|
27
|
+
items.forEach((item) => {
|
|
28
|
+
expect(item.url.startsWith(baseUrl)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("feed items are sorted by date descending", () => {
|
|
33
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
34
|
+
try {
|
|
35
|
+
siteConfig.feed.maxItems = 0;
|
|
36
|
+
const items = getFeedItems();
|
|
37
|
+
for (let i = 1; i < items.length; i++) {
|
|
38
|
+
expect(items[i - 1].date.getTime()).toBeGreaterThanOrEqual(
|
|
39
|
+
items[i].date.getTime()
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("maxItems = 0 returns all posts", () => {
|
|
48
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
49
|
+
const originalIncludeFlows = siteConfig.feed.includeFlows;
|
|
50
|
+
try {
|
|
51
|
+
siteConfig.feed.maxItems = 0;
|
|
52
|
+
siteConfig.feed.includeFlows = false;
|
|
53
|
+
const items = getFeedItems();
|
|
54
|
+
const allPosts = getAllPosts();
|
|
55
|
+
expect(items.length).toBe(allPosts.length);
|
|
56
|
+
} finally {
|
|
57
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
58
|
+
siteConfig.feed.includeFlows = originalIncludeFlows;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("maxItems limits returned items", () => {
|
|
63
|
+
const allPosts = getAllPosts();
|
|
64
|
+
if (allPosts.length < 2) return; // skip if not enough content
|
|
65
|
+
|
|
66
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
67
|
+
try {
|
|
68
|
+
siteConfig.feed.maxItems = 1;
|
|
69
|
+
const items = getFeedItems();
|
|
70
|
+
expect(items.length).toBe(1);
|
|
71
|
+
} finally {
|
|
72
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("respects configured maxItems", () => {
|
|
77
|
+
const items = getFeedItems();
|
|
78
|
+
const { maxItems } = siteConfig.feed;
|
|
79
|
+
if (maxItems > 0) {
|
|
80
|
+
expect(items.length).toBeLessThanOrEqual(maxItems);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("includeFlows adds flow notes to the feed", () => {
|
|
85
|
+
const flows = getAllFlows();
|
|
86
|
+
if (flows.length === 0) return; // skip if no flow content exists
|
|
87
|
+
|
|
88
|
+
const originalIncludeFlows = siteConfig.feed.includeFlows;
|
|
89
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
90
|
+
try {
|
|
91
|
+
siteConfig.feed.maxItems = 0;
|
|
92
|
+
|
|
93
|
+
siteConfig.feed.includeFlows = false;
|
|
94
|
+
const itemsWithout = getFeedItems();
|
|
95
|
+
|
|
96
|
+
siteConfig.feed.includeFlows = true;
|
|
97
|
+
const itemsWith = getFeedItems();
|
|
98
|
+
|
|
99
|
+
expect(itemsWith.length).toBeGreaterThan(itemsWithout.length);
|
|
100
|
+
} finally {
|
|
101
|
+
siteConfig.feed.includeFlows = originalIncludeFlows;
|
|
102
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("flow items include /flows/ in their URL", () => {
|
|
107
|
+
const flows = getAllFlows();
|
|
108
|
+
if (flows.length === 0) return;
|
|
109
|
+
|
|
110
|
+
const originalIncludeFlows = siteConfig.feed.includeFlows;
|
|
111
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
112
|
+
try {
|
|
113
|
+
siteConfig.feed.includeFlows = true;
|
|
114
|
+
siteConfig.feed.maxItems = 0;
|
|
115
|
+
const items = getFeedItems();
|
|
116
|
+
const flowItems = items.filter((item) => item.url.includes("/flows/"));
|
|
117
|
+
expect(flowItems.length).toBe(flows.length);
|
|
118
|
+
} finally {
|
|
119
|
+
siteConfig.feed.includeFlows = originalIncludeFlows;
|
|
120
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("feed item content is rendered HTML, not raw Markdown", () => {
|
|
125
|
+
const items = getFeedItems();
|
|
126
|
+
items.forEach((item) => {
|
|
127
|
+
if (item.content.trim().length === 0) return;
|
|
128
|
+
// Rendered HTML must contain at least one HTML tag
|
|
129
|
+
expect(item.content).toMatch(/<[a-z][^>]*>/i);
|
|
130
|
+
// Markdown headings should not appear outside code blocks
|
|
131
|
+
const strippedCodeBlocks = item.content.replace(/<pre[\s\S]*?<\/pre>/gi, '');
|
|
132
|
+
expect(strippedCodeBlocks).not.toMatch(/^#{1,6}\s/m);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("feed items with authors have a non-empty authors array", () => {
|
|
137
|
+
const items = getFeedItems();
|
|
138
|
+
items.forEach((item) => {
|
|
139
|
+
if (item.authors !== undefined) {
|
|
140
|
+
expect(Array.isArray(item.authors)).toBe(true);
|
|
141
|
+
expect(item.authors.length).toBeGreaterThan(0);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -76,4 +76,13 @@ describe("Integration: Series", () => {
|
|
|
76
76
|
expect(data?.featured).toBe(true);
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
test("getFeaturedSeries is a subset of getAllSeries", () => {
|
|
81
|
+
const all = getAllSeries();
|
|
82
|
+
const featured = getFeaturedSeries();
|
|
83
|
+
expect(Object.keys(featured).length).toBeLessThanOrEqual(Object.keys(all).length);
|
|
84
|
+
Object.keys(featured).forEach((slug) => {
|
|
85
|
+
expect(all).toHaveProperty(slug);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
79
88
|
});
|
|
@@ -20,13 +20,16 @@
|
|
|
20
20
|
* after beforeAll, so they pick up the mock correctly.
|
|
21
21
|
* • afterAll restores the real module so any subsequent tests see real data.
|
|
22
22
|
*/
|
|
23
|
-
import { describe, test, expect, mock, beforeAll, afterAll } from 'bun:test';
|
|
23
|
+
import { describe, test, expect, mock, beforeAll, beforeEach, afterAll, afterEach } from 'bun:test';
|
|
24
24
|
|
|
25
25
|
// ─── Capture real markdown module ────────────────────────────────────────────
|
|
26
26
|
// Static imports are hoisted and resolved before any executable code (including
|
|
27
27
|
// beforeAll / mock.module calls), so this always captures the real module.
|
|
28
28
|
import * as realMarkdown from '../../src/lib/markdown';
|
|
29
29
|
|
|
30
|
+
let mockedPosts: Array<{ slug: string }> = [];
|
|
31
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
32
|
+
|
|
30
33
|
// ─── Next.js runtime stubs (module-level — safe) ─────────────────────────────
|
|
31
34
|
mock.module('next/navigation', () => ({
|
|
32
35
|
notFound: () => { throw new Error('NOT_FOUND'); },
|
|
@@ -82,11 +85,13 @@ beforeAll(() => {
|
|
|
82
85
|
mock.module('@/lib/markdown', () => ({
|
|
83
86
|
getAllFlows: () => [],
|
|
84
87
|
getAllNotes: () => [],
|
|
85
|
-
getAllPosts: () =>
|
|
88
|
+
getAllPosts: () => mockedPosts,
|
|
86
89
|
getAllBooks: () => [],
|
|
87
90
|
getAllSeries: () => ({}),
|
|
88
91
|
getAllTags: () => ({}),
|
|
89
92
|
getAllAuthors: () => ({}),
|
|
93
|
+
getAllPages: () => [],
|
|
94
|
+
getListingPosts: () => [],
|
|
90
95
|
|
|
91
96
|
getFlowsByYear: () => [],
|
|
92
97
|
getFlowsByMonth: () => [],
|
|
@@ -124,6 +129,16 @@ beforeAll(() => {
|
|
|
124
129
|
}));
|
|
125
130
|
});
|
|
126
131
|
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
mockedPosts = [];
|
|
134
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
mockedPosts = [];
|
|
139
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
140
|
+
});
|
|
141
|
+
|
|
127
142
|
// ─── Restore real markdown module ─────────────────────────────────────────────
|
|
128
143
|
afterAll(() => {
|
|
129
144
|
mock.module('@/lib/markdown', () => realMarkdown);
|
|
@@ -206,6 +221,26 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
206
221
|
expect(params).toEqual([{ slug: '_' }]);
|
|
207
222
|
});
|
|
208
223
|
|
|
224
|
+
test('posts/[slug] includes raw and encoded Unicode slug in non-production', async () => {
|
|
225
|
+
mockedPosts = [{ slug: '中文测试文章' }];
|
|
226
|
+
process.env.NODE_ENV = 'development';
|
|
227
|
+
const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
|
|
228
|
+
const params = await generateStaticParams();
|
|
229
|
+
|
|
230
|
+
expect(params).toContainEqual({ slug: '中文测试文章' });
|
|
231
|
+
expect(params).toContainEqual({ slug: '%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95%E6%96%87%E7%AB%A0' });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('posts/[slug] includes only raw Unicode slug in production', async () => {
|
|
235
|
+
mockedPosts = [{ slug: '中文测试文章' }];
|
|
236
|
+
process.env.NODE_ENV = 'production';
|
|
237
|
+
const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
|
|
238
|
+
const params = await generateStaticParams();
|
|
239
|
+
|
|
240
|
+
expect(params).toContainEqual({ slug: '中文测试文章' });
|
|
241
|
+
expect(params).not.toContainEqual({ slug: '%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95%E6%96%87%E7%AB%A0' });
|
|
242
|
+
});
|
|
243
|
+
|
|
209
244
|
test('posts/page/[page] returns [{ page: "2" }]', async () => {
|
|
210
245
|
const { generateStaticParams } = await import('../../src/app/posts/page/[page]/page');
|
|
211
246
|
const params = generateStaticParams();
|
|
@@ -235,4 +270,26 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
235
270
|
});
|
|
236
271
|
});
|
|
237
272
|
|
|
273
|
+
describe('custom path routes', () => {
|
|
274
|
+
test('[slug]/page returns at least one param (static pages + no custom paths)', async () => {
|
|
275
|
+
const { generateStaticParams } = await import('../../src/app/[slug]/page');
|
|
276
|
+
const params = await generateStaticParams();
|
|
277
|
+
// With no pages, no custom basePath, and no series customPaths configured,
|
|
278
|
+
// the result is an empty array — but the route itself is static so this is valid.
|
|
279
|
+
expect(Array.isArray(params)).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('[slug]/[postSlug]/page returns [{ slug: "_", postSlug: "_" }] when no custom paths', async () => {
|
|
283
|
+
const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
|
|
284
|
+
const params = await generateStaticParams();
|
|
285
|
+
expect(params).toEqual([{ slug: '_', postSlug: '_' }]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('[slug]/page/[page]/page returns placeholder when no custom paths', async () => {
|
|
289
|
+
const { generateStaticParams } = await import('../../src/app/[slug]/page/[page]/page');
|
|
290
|
+
const params = await generateStaticParams();
|
|
291
|
+
expect(params).toEqual([{ slug: '_', page: '2' }]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
238
295
|
});
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { Metadata } from 'next';
|
|
2
|
-
import { siteConfig } from '../../../site.config';
|
|
3
|
-
import { resolveLocale } from '@/lib/i18n';
|
|
4
|
-
import SubscribePage from '@/components/SubscribePage';
|
|
5
|
-
|
|
6
|
-
export const metadata: Metadata = {
|
|
7
|
-
title: `Subscribe | ${resolveLocale(siteConfig.title)}`,
|
|
8
|
-
description: 'Stay updated with new posts and notes. Subscribe via RSS, email, Telegram, or WeChat.',
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export default function SubscribeRoute() {
|
|
12
|
-
return (
|
|
13
|
-
<div className="layout-main">
|
|
14
|
-
<SubscribePage />
|
|
15
|
-
</div>
|
|
16
|
-
);
|
|
17
|
-
}
|