@hutusi/amytis 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +33 -0
  2. package/.github/workflows/publish.yml +53 -0
  3. package/AGENTS.md +41 -0
  4. package/CLAUDE.md +200 -0
  5. package/GEMINI.md +84 -0
  6. package/README.md +172 -0
  7. package/TODO.md +76 -0
  8. package/bun.lock +1530 -0
  9. package/content/about.mdx +23 -0
  10. package/content/books/sample-book/index.mdx +24 -0
  11. package/content/books/sample-book/introduction.mdx +34 -0
  12. package/content/books/sample-book/setup.mdx +48 -0
  13. package/content/books/sample-book/writing-content.mdx +49 -0
  14. package/content/flows/2026/02/05.md +8 -0
  15. package/content/flows/2026/02/10.mdx +8 -0
  16. package/content/flows/2026/02/15.md +8 -0
  17. package/content/flows/2026/02/18.mdx +14 -0
  18. package/content/posts/2026-01-12-the-art-of-algorithms.mdx +49 -0
  19. package/content/posts/2026-01-15-nested-image-test/images/test.svg +5 -0
  20. package/content/posts/2026-01-15-nested-image-test/index.mdx +27 -0
  21. package/content/posts/2026-01-21-kitchen-sink/assets/test.svg +5 -0
  22. package/content/posts/2026-01-21-kitchen-sink/index.mdx +169 -0
  23. package/content/posts/asynchronous-javascript.mdx +49 -0
  24. package/content/posts/draft-post.mdx +13 -0
  25. package/content/posts/future-post.mdx +12 -0
  26. package/content/posts/legacy-markdown.md +60 -0
  27. package/content/posts/markdown-features.mdx +78 -0
  28. package/content/posts/modern-css-layouts.mdx +45 -0
  29. package/content/posts/multilingual-test.mdx +124 -0
  30. package/content/posts/syntax-highlighting-showcase.mdx +528 -0
  31. package/content/posts/understanding-react-hooks.mdx +48 -0
  32. package/content/posts/welcome-to-amytis.mdx +21 -0
  33. package/content/posts//344/270/255/346/226/207/346/265/213/350/257/225/346/226/207/347/253/240.mdx +54 -0
  34. package/content/series/ai-nexus-weekly/index.mdx +10 -0
  35. package/content/series/ai-nexus-weekly/week-1.mdx +20 -0
  36. package/content/series/ai-nexus-weekly/week-10.mdx +20 -0
  37. package/content/series/ai-nexus-weekly/week-11.mdx +20 -0
  38. package/content/series/ai-nexus-weekly/week-12.mdx +20 -0
  39. package/content/series/ai-nexus-weekly/week-2.mdx +20 -0
  40. package/content/series/ai-nexus-weekly/week-3.mdx +20 -0
  41. package/content/series/ai-nexus-weekly/week-4.mdx +20 -0
  42. package/content/series/ai-nexus-weekly/week-5.mdx +20 -0
  43. package/content/series/ai-nexus-weekly/week-6.mdx +20 -0
  44. package/content/series/ai-nexus-weekly/week-7.mdx +20 -0
  45. package/content/series/ai-nexus-weekly/week-8.mdx +20 -0
  46. package/content/series/ai-nexus-weekly/week-9.mdx +20 -0
  47. package/content/series/digital-garden/01-philosophy/index.mdx +23 -0
  48. package/content/series/digital-garden/01-philosophy.mdx +30 -0
  49. package/content/series/digital-garden/02-architecture.mdx +19 -0
  50. package/content/series/digital-garden/index.mdx +11 -0
  51. package/content/series/markdown-showcase/index.mdx +11 -0
  52. package/content/series/markdown-showcase/mathematical-notation.mdx +32 -0
  53. package/content/series/markdown-showcase/syntax-highlighting.mdx +119 -0
  54. package/content/series/markdown-showcase/visuals-and-diagrams.mdx +27 -0
  55. package/content/series/nextjs-deep-dive/01-getting-started.mdx +66 -0
  56. package/content/series/nextjs-deep-dive/02-routing-mastery/assets/diagram.svg +8 -0
  57. package/content/series/nextjs-deep-dive/02-routing-mastery/assets/m-p-model.png +0 -0
  58. package/content/series/nextjs-deep-dive/02-routing-mastery/index.mdx +138 -0
  59. package/content/series/nextjs-deep-dive/index.mdx +12 -0
  60. package/docs/ARCHITECTURE.md +103 -0
  61. package/docs/CONTRIBUTING.md +86 -0
  62. package/docs/deployment.md +319 -0
  63. package/eslint.config.mjs +18 -0
  64. package/next.config.ts +25 -0
  65. package/package.json +81 -0
  66. package/postcss.config.mjs +7 -0
  67. package/public/file.svg +1 -0
  68. package/public/globe.svg +1 -0
  69. package/public/icon.svg +9 -0
  70. package/public/logo.svg +11 -0
  71. package/public/next-image-export-optimizer-hashes.json +7 -0
  72. package/public/next.svg +1 -0
  73. package/public/screenshot.png +0 -0
  74. package/public/vercel.svg +1 -0
  75. package/public/window.svg +1 -0
  76. package/scripts/copy-assets.ts +211 -0
  77. package/scripts/new-flow.ts +47 -0
  78. package/scripts/new-from-images.ts +141 -0
  79. package/scripts/new-from-pdf.ts +105 -0
  80. package/scripts/new-post.ts +98 -0
  81. package/scripts/new-series.ts +40 -0
  82. package/scripts/series-draft.ts +136 -0
  83. package/site.config.ts +91 -0
  84. package/src/app/[slug]/page.tsx +67 -0
  85. package/src/app/archive/page.tsx +147 -0
  86. package/src/app/authors/[author]/page.tsx +210 -0
  87. package/src/app/books/[slug]/[chapter]/page.tsx +54 -0
  88. package/src/app/books/[slug]/page.tsx +156 -0
  89. package/src/app/books/page.tsx +63 -0
  90. package/src/app/favicon.ico +0 -0
  91. package/src/app/feed.xml/route.ts +44 -0
  92. package/src/app/flows/[year]/[month]/[day]/page.tsx +105 -0
  93. package/src/app/flows/[year]/[month]/page.tsx +72 -0
  94. package/src/app/flows/[year]/page.tsx +82 -0
  95. package/src/app/flows/page/[page]/page.tsx +63 -0
  96. package/src/app/flows/page.tsx +38 -0
  97. package/src/app/globals.css +406 -0
  98. package/src/app/layout.tsx +114 -0
  99. package/src/app/page/[page]/page.tsx +60 -0
  100. package/src/app/page.tsx +110 -0
  101. package/src/app/posts/[slug]/page.tsx +119 -0
  102. package/src/app/posts/page/[page]/page.tsx +58 -0
  103. package/src/app/posts/page.tsx +40 -0
  104. package/src/app/search.json/route.ts +49 -0
  105. package/src/app/series/[slug]/page/[page]/page.tsx +141 -0
  106. package/src/app/series/[slug]/page.tsx +139 -0
  107. package/src/app/series/page.tsx +96 -0
  108. package/src/app/sitemap.ts +112 -0
  109. package/src/app/tags/[tag]/page.tsx +76 -0
  110. package/src/app/tags/page.tsx +37 -0
  111. package/src/components/Analytics.tsx +49 -0
  112. package/src/components/AuthorStats.tsx +34 -0
  113. package/src/components/BookMobileNav.tsx +171 -0
  114. package/src/components/BookSidebar.tsx +275 -0
  115. package/src/components/CodeBlock.tsx +110 -0
  116. package/src/components/Comments.tsx +63 -0
  117. package/src/components/CoverImage.tsx +93 -0
  118. package/src/components/CuratedSeriesSection.tsx +124 -0
  119. package/src/components/ExternalLinks.tsx +45 -0
  120. package/src/components/FeaturedStoriesSection.tsx +106 -0
  121. package/src/components/FlowCalendarSidebar.tsx +249 -0
  122. package/src/components/FlowContent.tsx +96 -0
  123. package/src/components/FlowTimelineEntry.tsx +34 -0
  124. package/src/components/Footer.tsx +104 -0
  125. package/src/components/Hero.tsx +126 -0
  126. package/src/components/HorizontalScroll.tsx +128 -0
  127. package/src/components/LanguageProvider.tsx +80 -0
  128. package/src/components/LanguageSwitch.tsx +17 -0
  129. package/src/components/LatestWritingSection.tsx +45 -0
  130. package/src/components/MarkdownRenderer.tsx +135 -0
  131. package/src/components/Mermaid.tsx +89 -0
  132. package/src/components/Navbar.tsx +243 -0
  133. package/src/components/PageHeader.tsx +39 -0
  134. package/src/components/Pagination.tsx +120 -0
  135. package/src/components/PostCard.tsx +30 -0
  136. package/src/components/PostList.tsx +104 -0
  137. package/src/components/PostSidebar.tsx +225 -0
  138. package/src/components/ReadingProgressBar.tsx +37 -0
  139. package/src/components/RecentNotesSection.tsx +56 -0
  140. package/src/components/RelatedPosts.tsx +34 -0
  141. package/src/components/Search.tsx +151 -0
  142. package/src/components/SelectedBooksSection.tsx +80 -0
  143. package/src/components/SeriesCatalog.tsx +112 -0
  144. package/src/components/SeriesList.tsx +167 -0
  145. package/src/components/SeriesSidebar.tsx +132 -0
  146. package/src/components/SimpleLayoutHeader.tsx +38 -0
  147. package/src/components/Skeleton.tsx +131 -0
  148. package/src/components/TableOfContents.tsx +158 -0
  149. package/src/components/Tag.tsx +47 -0
  150. package/src/components/TagPageHeader.tsx +38 -0
  151. package/src/components/ThemeProvider.tsx +12 -0
  152. package/src/components/ThemeToggle.tsx +68 -0
  153. package/src/components/TranslatedText.tsx +13 -0
  154. package/src/fonts/Inter-Bold.woff2 +0 -0
  155. package/src/fonts/Inter-Regular.woff2 +0 -0
  156. package/src/fonts/LibreBaskerville-Bold.ttf +0 -0
  157. package/src/fonts/LibreBaskerville-Italic.ttf +0 -0
  158. package/src/fonts/LibreBaskerville-Regular.ttf +0 -0
  159. package/src/i18n/translations.ts +135 -0
  160. package/src/layouts/BookLayout.tsx +109 -0
  161. package/src/layouts/PostLayout.tsx +118 -0
  162. package/src/layouts/SimpleLayout.tsx +31 -0
  163. package/src/lib/i18n.ts +35 -0
  164. package/src/lib/markdown.test.ts +127 -0
  165. package/src/lib/markdown.ts +1067 -0
  166. package/src/lib/rehype-image-metadata.ts +54 -0
  167. package/src/lib/shuffle.ts +11 -0
  168. package/templates/default.mdx +13 -0
  169. package/tests/e2e/navigation.test.ts +51 -0
  170. package/tests/e2e/series-routes.test.ts +63 -0
  171. package/tests/e2e/smoke.test.ts +19 -0
  172. package/tests/integration/markdown-features.test.ts +54 -0
  173. package/tests/integration/posts.test.ts +57 -0
  174. package/tests/integration/reading-time-headings.test.ts +79 -0
  175. package/tests/integration/series-draft.test.ts +46 -0
  176. package/tests/integration/series.test.ts +79 -0
  177. package/tests/tooling/new-from-images.test.ts +173 -0
  178. package/tests/tooling/new-post.test.ts +72 -0
  179. package/tsconfig.json +34 -0
@@ -0,0 +1,118 @@
1
+ import Link from 'next/link';
2
+ import { getAuthorSlug, PostData } from '@/lib/markdown';
3
+ import MarkdownRenderer from '@/components/MarkdownRenderer';
4
+ import RelatedPosts from '@/components/RelatedPosts';
5
+ import SeriesList from '@/components/SeriesList';
6
+ import PostSidebar from '@/components/PostSidebar';
7
+ import Comments from '@/components/Comments';
8
+ import ExternalLinks from '@/components/ExternalLinks';
9
+ import Tag from '@/components/Tag';
10
+ import ReadingProgressBar from '@/components/ReadingProgressBar';
11
+ import { siteConfig } from '../../site.config';
12
+ import { t } from '@/lib/i18n';
13
+
14
+ interface PostLayoutProps {
15
+ post: PostData;
16
+ relatedPosts?: PostData[];
17
+ seriesPosts?: PostData[];
18
+ seriesTitle?: string;
19
+ }
20
+
21
+ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitle }: PostLayoutProps) {
22
+ const showToc = siteConfig.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
23
+ const hasSeries = !!(post.series && seriesPosts && seriesPosts.length > 0);
24
+ const showSidebar = showToc || hasSeries;
25
+
26
+ return (
27
+ <div className={`layout-container ${showSidebar ? 'lg:max-w-7xl' : 'lg:max-w-6xl'}`}>
28
+ <ReadingProgressBar />
29
+ <div className={showSidebar
30
+ ? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
31
+ : 'max-w-6xl mx-auto'
32
+ }>
33
+ {/* Left sidebar: series nav + page TOC */}
34
+ {showSidebar && (
35
+ <PostSidebar
36
+ seriesSlug={hasSeries ? post.series : undefined}
37
+ seriesTitle={hasSeries ? (seriesTitle || post.series) : undefined}
38
+ posts={hasSeries ? seriesPosts : undefined}
39
+ currentSlug={post.slug}
40
+ headings={showToc ? post.headings : []}
41
+ />
42
+ )}
43
+
44
+ <article className="min-w-0 max-w-3xl">
45
+ <header className="mb-16 border-b border-muted/10 pb-12">
46
+ {post.draft && (
47
+ <div className="mb-4">
48
+ <span className="text-xs font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded tracking-widest inline-block">
49
+ DRAFT
50
+ </span>
51
+ </div>
52
+ )}
53
+ <div className="flex items-center gap-3 text-xs font-sans text-muted mb-6">
54
+ <span className="uppercase tracking-widest font-semibold text-accent">
55
+ {post.category}
56
+ </span>
57
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
58
+ <time className="font-mono">{post.date}</time>
59
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
60
+ <span className="font-mono">{post.readingTime}</span>
61
+ </div>
62
+
63
+ <h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-6">
64
+ {post.title}
65
+ </h1>
66
+
67
+ <div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
68
+ <span>{t('written_by')}</span>
69
+ <div className="flex items-center gap-1">
70
+ {post.authors.map((author, index) => (
71
+ <span key={author} className="flex items-center">
72
+ <Link
73
+ href={`/authors/${getAuthorSlug(author)}`}
74
+ className="text-foreground hover:text-accent no-underline transition-colors duration-200"
75
+ >
76
+ {author}
77
+ </Link>
78
+ {index < post.authors.length - 1 && <span className="mr-1">,</span>}
79
+ </span>
80
+ ))}
81
+ </div>
82
+ </div>
83
+
84
+ {post.excerpt && (
85
+ <p className="text-xl text-foreground font-serif italic leading-relaxed mb-8">
86
+ {post.excerpt}
87
+ </p>
88
+ )}
89
+
90
+ {post.tags && post.tags.length > 0 && (
91
+ <div className="flex flex-wrap gap-2">
92
+ {post.tags.map((tag) => (
93
+ <Tag key={tag} tag={tag} variant="default" />
94
+ ))}
95
+ </div>
96
+ )}
97
+ </header>
98
+
99
+ {hasSeries && (
100
+ <div className="lg:hidden mb-12">
101
+ <SeriesList seriesSlug={post.series!} seriesTitle={seriesTitle || post.series!} posts={seriesPosts!} currentSlug={post.slug} />
102
+ </div>
103
+ )}
104
+
105
+ <MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
106
+
107
+ {post.externalLinks && post.externalLinks.length > 0 && (
108
+ <ExternalLinks links={post.externalLinks} />
109
+ )}
110
+
111
+ <RelatedPosts posts={relatedPosts || []} />
112
+
113
+ <Comments slug={post.slug} />
114
+ </article>
115
+ </div>
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,31 @@
1
+ import { PostData } from '@/lib/markdown';
2
+ import MarkdownRenderer from '@/components/MarkdownRenderer';
3
+ import SimpleLayoutHeader from '@/components/SimpleLayoutHeader';
4
+ import { TranslationKey } from '@/i18n/translations';
5
+
6
+ interface SimpleLayoutProps {
7
+ post: PostData;
8
+ titleKey?: TranslationKey;
9
+ subtitleKey?: TranslationKey;
10
+ titleOverride?: string | Record<string, string>;
11
+ subtitleOverride?: string | Record<string, string>;
12
+ }
13
+
14
+ export default function SimpleLayout({ post, titleKey, subtitleKey, titleOverride, subtitleOverride }: SimpleLayoutProps) {
15
+ return (
16
+ <div className="layout-main">
17
+ <article className="max-w-3xl mx-auto">
18
+ <SimpleLayoutHeader
19
+ title={post.title}
20
+ excerpt={post.excerpt}
21
+ titleKey={titleKey}
22
+ subtitleKey={subtitleKey}
23
+ titleOverride={titleOverride}
24
+ subtitleOverride={subtitleOverride}
25
+ />
26
+
27
+ <MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
28
+ </article>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,35 @@
1
+ import { translations, Language } from '@/i18n/translations';
2
+ import { siteConfig } from '../../site.config';
3
+
4
+ /**
5
+ * Server-side translation helper.
6
+ * For client components, use the `useLanguage()` hook instead.
7
+ */
8
+ export const t = (key: keyof typeof translations.en) =>
9
+ translations[siteConfig.i18n.defaultLocale as Language]?.[key] || translations.en[key];
10
+
11
+ export const tWith = (key: keyof typeof translations.en, params: Record<string, string | number>) => {
12
+ let result = t(key);
13
+ Object.entries(params).forEach(([k, v]) => {
14
+ result = result.split(`{${k}}`).join(String(v));
15
+ });
16
+ return result;
17
+ };
18
+
19
+ /**
20
+ * Resolve a locale-aware config value given an explicit language.
21
+ * Shared by both server-side resolveLocale() and client-side components.
22
+ */
23
+ export function resolveLocaleValue(value: string | Record<string, string>, lang: string): string {
24
+ if (typeof value === 'string') return value;
25
+ return value[lang] || value.en || Object.values(value)[0] || '';
26
+ }
27
+
28
+ /**
29
+ * Resolve a config value that may be a plain string or a locale map.
30
+ * Uses the default locale from site config (server-side / build-time).
31
+ * e.g. "Hello" or { en: "Hello", zh: "你好" }
32
+ */
33
+ export function resolveLocale(value: string | Record<string, string>): string {
34
+ return resolveLocaleValue(value, siteConfig.i18n.defaultLocale);
35
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateExcerpt, calculateReadingTime, getHeadings, getAuthorSlug } from "./markdown";
3
+
4
+ describe("markdown utils", () => {
5
+ describe("generateExcerpt", () => {
6
+ test("should return content as is if short enough", () => {
7
+ const text = "Hello world";
8
+ expect(generateExcerpt(text)).toBe("Hello world");
9
+ });
10
+
11
+ test("should truncate content longer than 160 chars", () => {
12
+ const longText = "a".repeat(200);
13
+ const excerpt = generateExcerpt(longText);
14
+ expect(excerpt.length).toBe(163); // 160 + "..."
15
+ expect(excerpt.endsWith("...")).toBe(true);
16
+ });
17
+
18
+ test("should strip markdown headers", () => {
19
+ const text = "# Header\nContent";
20
+ expect(generateExcerpt(text)).toBe("Header Content");
21
+ });
22
+
23
+ test("should strip bold and italic", () => {
24
+ const text = "This is **bold** and *italic*";
25
+ expect(generateExcerpt(text)).toBe("This is bold and italic");
26
+ });
27
+
28
+ test("should strip links but keep text", () => {
29
+ const text = "Check [this link](https://example.com)";
30
+ // generateExcerpt strips bold/italic markers and backticks but
31
+ // does not fully strip markdown link syntax
32
+ const result = generateExcerpt(text);
33
+ expect(result).toContain("this link");
34
+ });
35
+ });
36
+
37
+ describe("calculateReadingTime", () => {
38
+ test("short content returns 1 min read", () => {
39
+ const text = "Hello world, this is a short post.";
40
+ expect(calculateReadingTime(text)).toBe("1 min read");
41
+ });
42
+
43
+ test("600 words returns 3 min read", () => {
44
+ const words = Array(600).fill("word").join(" ");
45
+ expect(calculateReadingTime(words)).toBe("3 min read");
46
+ });
47
+
48
+ test("empty content returns 1 min read", () => {
49
+ expect(calculateReadingTime("")).toBe("1 min read");
50
+ });
51
+
52
+ test("strips markdown formatting before counting", () => {
53
+ // 400 actual words surrounded by markdown syntax
54
+ const words = Array(400).fill("**word**").join(" ");
55
+ const result = calculateReadingTime(words);
56
+ expect(result).toBe("2 min read");
57
+ });
58
+
59
+ test("counts Chinese characters for reading time", () => {
60
+ const han = "中".repeat(600);
61
+ expect(calculateReadingTime(han)).toBe("2 min read");
62
+ });
63
+
64
+ test("combines Latin words and Chinese characters", () => {
65
+ const latinWords = Array(200).fill("word").join(" ");
66
+ const han = "中".repeat(300);
67
+ const mixed = `${latinWords} ${han}`;
68
+ expect(calculateReadingTime(mixed)).toBe("2 min read");
69
+ });
70
+ });
71
+
72
+ describe("getHeadings", () => {
73
+ test("extracts H2 headings with slugified IDs", () => {
74
+ const content = "## Hello World\n\nSome text\n\n## Another Section";
75
+ const headings = getHeadings(content);
76
+ expect(headings).toHaveLength(2);
77
+ expect(headings[0]).toEqual({ id: "hello-world", text: "Hello World", level: 2 });
78
+ expect(headings[1]).toEqual({ id: "another-section", text: "Another Section", level: 2 });
79
+ });
80
+
81
+ test("extracts H3 headings", () => {
82
+ const content = "### Sub Section\n\nSome text";
83
+ const headings = getHeadings(content);
84
+ expect(headings).toHaveLength(1);
85
+ expect(headings[0]).toEqual({ id: "sub-section", text: "Sub Section", level: 3 });
86
+ });
87
+
88
+ test("preserves document order for mixed H2/H3", () => {
89
+ const content = "## First\n\n### Nested\n\n## Second";
90
+ const headings = getHeadings(content);
91
+ expect(headings).toHaveLength(3);
92
+ expect(headings[0].level).toBe(2);
93
+ expect(headings[1].level).toBe(3);
94
+ expect(headings[2].level).toBe(2);
95
+ });
96
+
97
+ test("ignores H1 and H4+ headings", () => {
98
+ const content = "# Title\n\n## Included\n\n#### Not Included\n\n##### Also Not";
99
+ const headings = getHeadings(content);
100
+ expect(headings).toHaveLength(1);
101
+ expect(headings[0].text).toBe("Included");
102
+ });
103
+
104
+ test("handles Unicode headings", () => {
105
+ const content = "## 核心特性\n\nContent\n\n## Résumé";
106
+ const headings = getHeadings(content);
107
+ expect(headings).toHaveLength(2);
108
+ expect(headings[0].text).toBe("核心特性");
109
+ expect(headings[0].id).toBeTruthy();
110
+ expect(headings[1].text).toBe("Résumé");
111
+ });
112
+
113
+ test("returns empty array for no headings", () => {
114
+ const content = "Just plain text with no headings at all.";
115
+ const headings = getHeadings(content);
116
+ expect(headings).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe("getAuthorSlug", () => {
121
+ test("creates stable, URL-safe slugs for author names", () => {
122
+ expect(getAuthorSlug("Amytis Team")).toBe("amytis-team");
123
+ expect(getAuthorSlug("[author]")).toBe("author");
124
+ expect(getAuthorSlug(" John Hu ")).toBe("john-hu");
125
+ });
126
+ });
127
+ });