@hutusi/amytis 1.15.0 → 1.17.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.
Files changed (120) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +89 -219
  5. package/bun.lock +185 -547
  6. package/content/books/sample-book/index.mdx +3 -0
  7. package/content/posts/code-block-features-showcase.mdx +223 -0
  8. package/docs/ALERTS.md +112 -0
  9. package/docs/ARCHITECTURE.md +298 -5
  10. package/docs/CODE-BLOCKS.md +238 -0
  11. package/docs/CONTRIBUTING.md +25 -0
  12. package/docs/DIGITAL_GARDEN.md +1 -1
  13. package/docs/guides/README.md +11 -0
  14. package/docs/guides/importing-vuepress-books.md +237 -0
  15. package/eslint.config.mjs +18 -6
  16. package/package.json +42 -20
  17. package/scripts/generate-code-group-icons.ts +79 -0
  18. package/scripts/render-rst.py +207 -3
  19. package/scripts/sync-vuepress-book.ts +710 -0
  20. package/site.config.example.ts +3 -3
  21. package/site.config.ts +3 -3
  22. package/src/app/[slug]/layout.tsx +30 -0
  23. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  24. package/src/app/books/[slug]/layout.tsx +24 -0
  25. package/src/app/books/[slug]/page.tsx +85 -34
  26. package/src/app/globals.css +570 -123
  27. package/src/app/page.tsx +7 -1
  28. package/src/app/posts/layout.tsx +20 -0
  29. package/src/app/series/[slug]/page.tsx +33 -9
  30. package/src/app/sitemap.ts +3 -3
  31. package/src/components/ArticleCopyCleaner.tsx +64 -0
  32. package/src/components/BookMobileNav.tsx +44 -50
  33. package/src/components/BookReadingShell.tsx +145 -0
  34. package/src/components/BookSidebar.tsx +0 -0
  35. package/src/components/CodeBlock.test.tsx +93 -8
  36. package/src/components/CodeBlock.tsx +39 -101
  37. package/src/components/CodeBlockToolbar.tsx +88 -0
  38. package/src/components/CodeGroup.tsx +81 -0
  39. package/src/components/CoverImage.tsx +1 -0
  40. package/src/components/CuratedSeriesSection.tsx +28 -10
  41. package/src/components/ExternalLinkIcon.tsx +15 -0
  42. package/src/components/FeaturedStoriesSection.tsx +44 -23
  43. package/src/components/Footer.tsx +1 -1
  44. package/src/components/GithubAlert.tsx +97 -0
  45. package/src/components/ImmersiveReader.tsx +130 -0
  46. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  47. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  48. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  49. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  50. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  51. package/src/components/ImmersiveToggleButton.tsx +45 -0
  52. package/src/components/MarkdownRenderer.test.tsx +14 -4
  53. package/src/components/MarkdownRenderer.tsx +175 -23
  54. package/src/components/Mermaid.tsx +32 -1
  55. package/src/components/Navbar.tsx +3 -1
  56. package/src/components/PostList.tsx +1 -1
  57. package/src/components/PostNavigation.tsx +13 -2
  58. package/src/components/PostReadingShell.tsx +68 -0
  59. package/src/components/PostSidebar.tsx +13 -2
  60. package/src/components/ReadingProgressBar.tsx +1 -1
  61. package/src/components/RstRenderer.test.tsx +15 -15
  62. package/src/components/RstRenderer.tsx +37 -2
  63. package/src/components/Search.tsx +18 -4
  64. package/src/components/SelectedBooksSection.tsx +27 -8
  65. package/src/components/SeriesCatalog.tsx +1 -1
  66. package/src/components/ShareBar.tsx +5 -0
  67. package/src/components/TocPanel.tsx +10 -2
  68. package/src/hooks/useActiveHeading.ts +35 -13
  69. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  70. package/src/i18n/translations.ts +44 -0
  71. package/src/layouts/BookLayout.tsx +62 -74
  72. package/src/layouts/PostLayout.tsx +154 -111
  73. package/src/lib/code-group-icons.test.ts +78 -0
  74. package/src/lib/code-group-icons.ts +148 -0
  75. package/src/lib/immersive-reading-prefs.ts +104 -0
  76. package/src/lib/markdown.test.ts +56 -13
  77. package/src/lib/markdown.ts +217 -57
  78. package/src/lib/normalize-vuepress-math.ts +118 -0
  79. package/src/lib/rehype-fence-meta.ts +22 -0
  80. package/src/lib/remark-book-chapter-links.ts +106 -0
  81. package/src/lib/remark-code-group.ts +54 -0
  82. package/src/lib/remark-github-alerts.test.ts +83 -0
  83. package/src/lib/remark-github-alerts.ts +65 -0
  84. package/src/lib/remark-vuepress-containers.ts +130 -0
  85. package/src/lib/rst-renderer.ts +19 -7
  86. package/src/lib/rst.test.ts +212 -2
  87. package/src/lib/rst.ts +217 -13
  88. package/src/lib/scroll-utils.ts +44 -6
  89. package/src/lib/shiki-rst.ts +185 -0
  90. package/src/lib/shiki.test.ts +153 -0
  91. package/src/lib/shiki.ts +292 -0
  92. package/src/lib/shuffle.ts +15 -1
  93. package/src/lib/sort.ts +15 -0
  94. package/src/lib/urls.ts +62 -0
  95. package/src/test-utils/render.ts +23 -0
  96. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  97. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  98. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  99. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  100. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  101. package/tests/helpers/env.ts +19 -0
  102. package/tests/integration/book-chapter-links.test.ts +107 -0
  103. package/tests/integration/book-index-cta.test.ts +87 -0
  104. package/tests/integration/books-nested-toc.test.ts +176 -0
  105. package/tests/integration/books.test.ts +3 -2
  106. package/tests/integration/code-block-features.test.ts +188 -0
  107. package/tests/integration/code-group.test.ts +183 -0
  108. package/tests/integration/code-notation.test.ts +97 -0
  109. package/tests/integration/github-alerts.test.ts +82 -0
  110. package/tests/integration/markdown-external-links.test.ts +103 -0
  111. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  112. package/tests/integration/reading-time-headings.test.ts +8 -6
  113. package/tests/integration/series-draft.test.ts +6 -13
  114. package/tests/integration/series-index-cta.test.ts +88 -0
  115. package/tests/integration/sync-vuepress-book.test.ts +443 -0
  116. package/tests/integration/vuepress-containers.test.ts +107 -0
  117. package/tests/tooling/new-post.test.ts +1 -1
  118. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  119. package/tests/unit/static-params.test.ts +32 -19
  120. package/vercel.json +7 -0
@@ -1,11 +1,7 @@
1
- import { BookData, BookChapterData } from '@/lib/markdown';
1
+ import path from 'path';
2
+ import { BookData, BookChapterData, getBookDirPath } from '@/lib/markdown';
2
3
  import MarkdownRenderer from '@/components/MarkdownRenderer';
3
- import BookSidebar from '@/components/BookSidebar';
4
- import BookMobileNav from '@/components/BookMobileNav';
5
- import PrevNextNav from '@/components/PrevNextNav';
6
- import ReadingProgressBar from '@/components/ReadingProgressBar';
7
- import Comments from '@/components/Comments';
8
- import { t } from '@/lib/i18n';
4
+ import BookReadingShell from '@/components/BookReadingShell';
9
5
  import { getBookChapterUrl } from '@/lib/urls';
10
6
  import { siteConfig } from '../../site.config';
11
7
  import { resolveCommentable } from '@/lib/comments';
@@ -16,75 +12,67 @@ interface BookLayoutProps {
16
12
  }
17
13
 
18
14
  export default function BookLayout({ book, chapter }: BookLayoutProps) {
19
- return (
20
- <div className="layout-container lg:max-w-7xl">
21
- <ReadingProgressBar />
22
- <div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
23
- {/* Left: Sidebar */}
24
- <BookSidebar
25
- bookSlug={book.slug}
26
- bookTitle={book.title}
27
- toc={book.toc}
28
- chapters={book.chapters}
29
- currentChapter={chapter.slug}
30
- headings={chapter.headings}
31
- />
32
-
33
- {/* Main content */}
34
- <article className="min-w-0 w-full max-w-3xl overflow-x-hidden">
35
- {/* Mobile nav */}
36
- <div className="lg:hidden mb-8">
37
- <BookMobileNav
38
- bookSlug={book.slug}
39
- bookTitle={book.title}
40
- toc={book.toc}
41
- chapters={book.chapters}
42
- currentChapter={chapter.slug}
43
- />
44
- </div>
45
-
46
- {/* Chapter header */}
47
- <header className="mb-12 pb-8 border-b border-muted/10">
48
- <div className="flex items-center gap-3 text-xs font-sans text-muted mb-4">
49
- <span className="uppercase tracking-widest font-semibold text-accent">
50
- {t('chapter')}
51
- </span>
52
- <span className="w-1 h-1 rounded-full bg-muted/30" />
53
- <span className="font-mono">{chapter.readingTime}</span>
54
- </div>
15
+ const bookDir = getBookDirPath(book.slug);
16
+ const validChapterIds = new Set(book.chapters.map(c => c.id));
55
17
 
56
- <h1 className="text-3xl md:text-4xl font-serif font-bold text-heading leading-tight mb-4">
57
- {chapter.title}
58
- </h1>
18
+ // `slug` is the public-relative directory used by rehype-image-metadata to
19
+ // resolve `![](./assets/...)`-style refs. For nested flat chapters
20
+ // (e.g. id `maths/linear/vectors`) the image's parent dir is the chapter's
21
+ // parent dir, not the book root — without this, all chapter images point
22
+ // at `/books/<slug>/assets/...` instead of `/books/<slug>/<dir>/assets/...`.
23
+ let imageSlug: string;
24
+ if (chapter.isFolder) {
25
+ imageSlug = `books/${book.slug}/${chapter.slug}`;
26
+ } else {
27
+ const parentDir = path.posix.dirname(chapter.slug);
28
+ imageSlug = parentDir === '.' ? `books/${book.slug}` : `books/${book.slug}/${parentDir}`;
29
+ }
59
30
 
60
- {chapter.excerpt && (
61
- <p className="text-lg text-muted font-serif italic leading-relaxed">
62
- {chapter.excerpt}
63
- </p>
64
- )}
65
- </header>
31
+ const prev = chapter.prevChapter
32
+ ? { href: getBookChapterUrl(book.slug, chapter.prevChapter.id), title: chapter.prevChapter.title }
33
+ : null;
34
+ const next = chapter.nextChapter
35
+ ? { href: getBookChapterUrl(book.slug, chapter.nextChapter.id), title: chapter.nextChapter.title }
36
+ : null;
37
+ const comments = resolveCommentable(chapter.commentable, 'bookChapters')
38
+ ? {
39
+ slug: `books/${book.slug}/${chapter.slug}`,
40
+ postUrl: `${siteConfig.baseUrl.replace(/\/+$/, '')}${getBookChapterUrl(book.slug, chapter.slug)}`,
41
+ }
42
+ : null;
66
43
 
67
- {/* Content */}
68
- <MarkdownRenderer content={chapter.content} latex={chapter.latex} slug={chapter.isFolder ? `books/${book.slug}/${chapter.slug}` : `books/${book.slug}`} />
69
-
70
- {/* Comments */}
71
- {resolveCommentable(chapter.commentable, 'bookChapters') && (
72
- <Comments
73
- slug={`books/${book.slug}/${chapter.slug}`}
74
- postUrl={`${siteConfig.baseUrl.replace(/\/+$/, '')}${getBookChapterUrl(book.slug, chapter.slug)}`}
75
- />
76
- )}
77
-
78
- {/* Prev/Next navigation */}
79
- <div className="mt-16 pt-8 border-t border-muted/10">
80
- <PrevNextNav
81
- prev={chapter.prevChapter ? { href: getBookChapterUrl(book.slug, chapter.prevChapter.id), title: chapter.prevChapter.title } : null}
82
- next={chapter.nextChapter ? { href: getBookChapterUrl(book.slug, chapter.nextChapter.id), title: chapter.nextChapter.title } : null}
83
- size="lg"
84
- />
85
- </div>
86
- </article>
87
- </div>
88
- </div>
44
+ return (
45
+ <BookReadingShell
46
+ book={{
47
+ slug: book.slug,
48
+ title: book.title,
49
+ toc: book.toc,
50
+ chapters: book.chapters,
51
+ showChapterExcerpt: book.showChapterExcerpt,
52
+ }}
53
+ chapter={{
54
+ slug: chapter.slug,
55
+ title: chapter.title,
56
+ wordCount: chapter.wordCount,
57
+ readingMinutes: chapter.readingMinutes,
58
+ excerpt: chapter.excerpt,
59
+ headings: chapter.headings,
60
+ }}
61
+ prev={prev}
62
+ next={next}
63
+ comments={comments}
64
+ >
65
+ <MarkdownRenderer
66
+ content={chapter.content}
67
+ latex={chapter.latex}
68
+ slug={imageSlug}
69
+ bookContext={{
70
+ bookSlug: book.slug,
71
+ bookDir,
72
+ chapterSourcePath: chapter.sourcePath,
73
+ validChapterIds,
74
+ }}
75
+ />
76
+ </BookReadingShell>
89
77
  );
90
78
  }
@@ -15,6 +15,8 @@ import ReadingProgressBar from '@/components/ReadingProgressBar';
15
15
  import PostNavigation from '@/components/PostNavigation';
16
16
  import AuthorCard from '@/components/AuthorCard';
17
17
  import ShareBar from '@/components/ShareBar';
18
+ import ImmersiveToggleButton from '@/components/ImmersiveToggleButton';
19
+ import PostReadingShell from '@/components/PostReadingShell';
18
20
  import { siteConfig } from '../../site.config';
19
21
  import { t } from '@/lib/i18n';
20
22
  import { getPostUrl, getStaticPageUrl } from '@/lib/urls';
@@ -46,138 +48,179 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
46
48
  ? <RstRenderer content={post.content} html={post.renderedHtml} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />
47
49
  : <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />;
48
50
 
49
- return (
50
- <div className="layout-container">
51
- <ReadingProgressBar />
52
- <div className={showSidebar
53
- ? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
54
- : 'max-w-6xl mx-auto'
55
- }>
56
- {/* Left sidebar: series nav + page TOC */}
57
- {showSidebar && (
58
- <Suspense fallback={null}>
59
- <PostSidebar
60
- seriesSlug={hasSeries ? post.series : undefined}
61
- seriesTitle={hasSeries ? (seriesTitle || post.series) : undefined}
62
- posts={hasSeries ? seriesPosts : undefined}
63
- collectionContexts={collectionContexts}
64
- currentSlug={post.slug}
65
- headings={showToc ? post.headings : []}
66
- shareUrl={postUrl}
67
- shareTitle={post.title}
68
- />
69
- </Suspense>
51
+ // The article <header> is identical in both the normal and the immersive
52
+ // article subtrees — extracting it as a variable lets us reference it in
53
+ // both places (only one of the two trees ever mounts, so it renders once).
54
+ // Includes the immersive toggle when the post is in a series.
55
+ const articleHeader = (
56
+ <header className="mb-16 border-b border-muted/10 pb-8">
57
+ {post.draft && (
58
+ <div className="mb-4">
59
+ <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">
60
+ DRAFT
61
+ </span>
62
+ </div>
63
+ )}
64
+ <div className="flex items-center gap-3 text-xs font-sans text-muted mb-6">
65
+ <span className="uppercase tracking-widest font-semibold text-accent">
66
+ {post.category}
67
+ </span>
68
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
69
+ <time className="font-mono" data-pagefind-meta="date[content]">{post.date}</time>
70
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
71
+ <span className="font-mono">
72
+ {post.wordCount.toLocaleString()} {t('words')}
73
+ </span>
74
+ <span className="w-1 h-1 rounded-full bg-muted/30" />
75
+ <span className="font-mono text-muted/70">{post.readingMinutes} {t('reading_time')}</span>
76
+ {hasSeries && (
77
+ <span className="ml-auto">
78
+ <ImmersiveToggleButton />
79
+ </span>
70
80
  )}
81
+ </div>
71
82
 
72
- <article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
73
- <header className="mb-16 border-b border-muted/10 pb-8">
74
- {post.draft && (
75
- <div className="mb-4">
76
- <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">
77
- DRAFT
78
- </span>
79
- </div>
80
- )}
81
- <div className="flex items-center gap-3 text-xs font-sans text-muted mb-6">
82
- <span className="uppercase tracking-widest font-semibold text-accent">
83
- {post.category}
83
+ <h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-4">
84
+ {post.title}
85
+ </h1>
86
+
87
+ {post.subtitle && (
88
+ <p className="text-xl md:text-2xl font-serif italic text-muted leading-snug mb-6">
89
+ {post.subtitle}
90
+ </p>
91
+ )}
92
+
93
+ {siteConfig.posts?.authors?.showInHeader !== false && post.authors.length > 0 && (
94
+ <div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
95
+ <span>{t('written_by')}</span>
96
+ <div className="flex items-center gap-1">
97
+ {post.authors.map((author, index) => (
98
+ <span key={author} className="flex items-center">
99
+ <Link
100
+ href={`/authors/${getAuthorSlug(author)}`}
101
+ className="text-foreground hover:text-accent no-underline transition-colors duration-200"
102
+ >
103
+ {author}
104
+ </Link>
105
+ {index < post.authors.length - 1 && <span className="mr-1">,</span>}
84
106
  </span>
85
- <span className="w-1 h-1 rounded-full bg-muted/30" />
86
- <time className="font-mono" data-pagefind-meta="date[content]">{post.date}</time>
87
- <span className="w-1 h-1 rounded-full bg-muted/30" />
88
- <span className="font-mono">{post.readingTime}</span>
89
- </div>
90
-
91
- <h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-4">
92
- {post.title}
93
- </h1>
94
-
95
- {post.subtitle && (
96
- <p className="text-xl md:text-2xl font-serif italic text-muted leading-snug mb-6">
97
- {post.subtitle}
98
- </p>
99
- )}
107
+ ))}
108
+ </div>
109
+ </div>
110
+ )}
111
+
112
+ {post.excerpt && (
113
+ <p className="text-xl text-foreground font-serif italic leading-relaxed mb-8">
114
+ {post.excerpt}
115
+ </p>
116
+ )}
117
+
118
+ {post.tags && post.tags.length > 0 && (
119
+ <div className="flex flex-wrap gap-2">
120
+ {post.tags.map((tag) => (
121
+ <Tag key={tag} tag={tag} variant="default" />
122
+ ))}
123
+ </div>
124
+ )}
125
+ </header>
126
+ );
127
+
128
+ // Slim subtree for the immersive overlay: header + body + prev/next nav. We
129
+ // deliberately skip AuthorCard / ShareBar / Comments / RelatedPosts here —
130
+ // the reader is focused on long-form reading; chrome lives in normal mode.
131
+ const overlayArticle = (
132
+ <>
133
+ {articleHeader}
134
+ {bodyRenderer}
135
+ <div className="mt-16 pt-8 border-t border-muted/10">
136
+ <Suspense fallback={null}>
137
+ <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
138
+ </Suspense>
139
+ </div>
140
+ </>
141
+ );
142
+
143
+ return (
144
+ <PostReadingShell
145
+ post={post}
146
+ seriesSlug={hasSeries ? post.series : undefined}
147
+ seriesTitle={hasSeries ? (seriesTitle || post.series) : undefined}
148
+ seriesPosts={hasSeries ? seriesPosts : undefined}
149
+ collectionContexts={collectionContexts}
150
+ overlayArticle={overlayArticle}
151
+ >
152
+ <div className="layout-container">
153
+ <ReadingProgressBar />
154
+ <div className={showSidebar
155
+ ? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
156
+ : 'max-w-6xl mx-auto'
157
+ }>
158
+ {/* Left sidebar: series nav + page TOC */}
159
+ {showSidebar && (
160
+ <Suspense fallback={null}>
161
+ <PostSidebar
162
+ seriesSlug={hasSeries ? post.series : undefined}
163
+ seriesTitle={hasSeries ? (seriesTitle || post.series) : undefined}
164
+ posts={hasSeries ? seriesPosts : undefined}
165
+ collectionContexts={collectionContexts}
166
+ currentSlug={post.slug}
167
+ headings={showToc ? post.headings : []}
168
+ shareUrl={postUrl}
169
+ shareTitle={post.title}
170
+ />
171
+ </Suspense>
172
+ )}
100
173
 
101
- {siteConfig.posts?.authors?.showInHeader !== false && post.authors.length > 0 && (
102
- <div className="flex items-center gap-2 mb-8 text-sm font-serif italic text-muted">
103
- <span>{t('written_by')}</span>
104
- <div className="flex items-center gap-1">
105
- {post.authors.map((author, index) => (
106
- <span key={author} className="flex items-center">
107
- <Link
108
- href={`/authors/${getAuthorSlug(author)}`}
109
- className="text-foreground hover:text-accent no-underline transition-colors duration-200"
110
- >
111
- {author}
112
- </Link>
113
- {index < post.authors.length - 1 && <span className="mr-1">,</span>}
114
- </span>
115
- ))}
116
- </div>
174
+ <article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
175
+ {articleHeader}
176
+
177
+ {(hasSeries || (collectionContexts && collectionContexts.length > 0)) && (
178
+ <div className="lg:hidden mb-12">
179
+ <Suspense fallback={null}>
180
+ <SeriesList seriesSlug={hasSeries ? post.series! : undefined} seriesTitle={hasSeries ? (seriesTitle || post.series!) : undefined} posts={hasSeries ? seriesPosts! : undefined} collectionContexts={collectionContexts} currentSlug={post.slug} />
181
+ </Suspense>
117
182
  </div>
118
183
  )}
119
184
 
120
- {post.excerpt && (
121
- <p className="text-xl text-foreground font-serif italic leading-relaxed mb-8">
122
- {post.excerpt}
123
- </p>
185
+ {bodyRenderer}
186
+
187
+ {siteConfig.posts?.authors?.showAuthorCard !== false && (
188
+ <AuthorCard authors={post.authors} />
124
189
  )}
125
190
 
126
191
  {post.tags && post.tags.length > 0 && (
127
- <div className="flex flex-wrap gap-2">
192
+ <div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
193
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mr-1">{t('tags')}</span>
128
194
  {post.tags.map((tag) => (
129
195
  <Tag key={tag} tag={tag} variant="default" />
130
196
  ))}
131
197
  </div>
132
198
  )}
133
- </header>
134
-
135
- {(hasSeries || (collectionContexts && collectionContexts.length > 0)) && (
136
- <div className="lg:hidden mb-12">
137
- <Suspense fallback={null}>
138
- <SeriesList seriesSlug={hasSeries ? post.series! : undefined} seriesTitle={hasSeries ? (seriesTitle || post.series!) : undefined} posts={hasSeries ? seriesPosts! : undefined} collectionContexts={collectionContexts} currentSlug={post.slug} />
139
- </Suspense>
140
- </div>
141
- )}
142
-
143
- {bodyRenderer}
144
199
 
145
- {siteConfig.posts?.authors?.showAuthorCard !== false && (
146
- <AuthorCard authors={post.authors} />
147
- )}
148
-
149
- {post.tags && post.tags.length > 0 && (
150
- <div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
151
- <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mr-1">{t('tags')}</span>
152
- {post.tags.map((tag) => (
153
- <Tag key={tag} tag={tag} variant="default" />
154
- ))}
155
- </div>
156
- )}
200
+ <Backlinks backlinks={backlinks ?? []} />
157
201
 
158
- <Backlinks backlinks={backlinks ?? []} />
159
-
160
- <ShareBar
161
- url={postUrl}
162
- title={post.title}
163
- className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
164
- />
202
+ <ShareBar
203
+ url={postUrl}
204
+ title={post.title}
205
+ className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
206
+ />
165
207
 
166
- {resolveCommentable(post.commentable, commentCategory) && (
167
- <Comments slug={commentSlug} postUrl={postUrl} />
168
- )}
208
+ {resolveCommentable(post.commentable, commentCategory) && (
209
+ <Comments slug={commentSlug} postUrl={postUrl} />
210
+ )}
169
211
 
170
- {post.externalLinks && post.externalLinks.length > 0 && (
171
- <ExternalLinks links={post.externalLinks} />
172
- )}
212
+ {post.externalLinks && post.externalLinks.length > 0 && (
213
+ <ExternalLinks links={post.externalLinks} />
214
+ )}
173
215
 
174
- <Suspense fallback={null}>
175
- <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
176
- </Suspense>
216
+ <Suspense fallback={null}>
217
+ <PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
218
+ </Suspense>
177
219
 
178
- <RelatedPosts posts={relatedPosts || []} />
179
- </article>
220
+ <RelatedPosts posts={relatedPosts || []} />
221
+ </article>
222
+ </div>
180
223
  </div>
181
- </div>
224
+ </PostReadingShell>
182
225
  );
183
226
  }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { resolveCodeGroupIcon } from './code-group-icons';
3
+
4
+ describe('resolveCodeGroupIcon', () => {
5
+ test('exact-label matches for package managers', () => {
6
+ expect(resolveCodeGroupIcon('npm')).toBe('npm');
7
+ expect(resolveCodeGroupIcon('yarn')).toBe('yarn');
8
+ expect(resolveCodeGroupIcon('pnpm')).toBe('pnpm');
9
+ expect(resolveCodeGroupIcon('bun')).toBe('bun');
10
+ expect(resolveCodeGroupIcon('deno')).toBe('deno');
11
+ });
12
+
13
+ test('exact-label matches are case-insensitive and trim whitespace', () => {
14
+ expect(resolveCodeGroupIcon('NPM')).toBe('npm');
15
+ expect(resolveCodeGroupIcon(' Yarn ')).toBe('yarn');
16
+ });
17
+
18
+ test('exact-label matches for tools', () => {
19
+ expect(resolveCodeGroupIcon('docker')).toBe('docker');
20
+ expect(resolveCodeGroupIcon('vite')).toBe('vite');
21
+ expect(resolveCodeGroupIcon('next.js')).toBe('nextjs');
22
+ expect(resolveCodeGroupIcon('nodejs')).toBe('node');
23
+ expect(resolveCodeGroupIcon('tailwindcss')).toBe('tailwind');
24
+ });
25
+
26
+ test('filename matches win over extension matches', () => {
27
+ // tsconfig.json maps to typescript via the filename table; otherwise
28
+ // its `.json` extension would route it to the json icon.
29
+ expect(resolveCodeGroupIcon('tsconfig.json')).toBe('typescript');
30
+ expect(resolveCodeGroupIcon('package.json')).toBe('node');
31
+ expect(resolveCodeGroupIcon('Dockerfile')).toBe('docker');
32
+ expect(resolveCodeGroupIcon('vite.config.ts')).toBe('vite');
33
+ expect(resolveCodeGroupIcon('next.config.mjs')).toBe('nextjs');
34
+ expect(resolveCodeGroupIcon('tailwind.config.js')).toBe('tailwind');
35
+ });
36
+
37
+ test('filename match strips directory paths', () => {
38
+ expect(resolveCodeGroupIcon('src/app/Dockerfile')).toBe('docker');
39
+ expect(resolveCodeGroupIcon('apps/web/package.json')).toBe('node');
40
+ });
41
+
42
+ test('extension match for arbitrary file paths', () => {
43
+ expect(resolveCodeGroupIcon('foo.ts')).toBe('typescript');
44
+ expect(resolveCodeGroupIcon('src/index.tsx')).toBe('typescript');
45
+ expect(resolveCodeGroupIcon('hello.py')).toBe('python');
46
+ expect(resolveCodeGroupIcon('main.rs')).toBe('rust');
47
+ expect(resolveCodeGroupIcon('config.yml')).toBe('yaml');
48
+ expect(resolveCodeGroupIcon('README.md')).toBe('markdown');
49
+ expect(resolveCodeGroupIcon('install.sh')).toBe('bash');
50
+ });
51
+
52
+ test('language-name aliases resolve to a canonical icon key', () => {
53
+ expect(resolveCodeGroupIcon('TypeScript')).toBe('typescript');
54
+ expect(resolveCodeGroupIcon('ts')).toBe('typescript');
55
+ expect(resolveCodeGroupIcon('Python')).toBe('python');
56
+ expect(resolveCodeGroupIcon('Go')).toBe('go');
57
+ expect(resolveCodeGroupIcon('golang')).toBe('go');
58
+ expect(resolveCodeGroupIcon('c++')).toBe('cpp');
59
+ });
60
+
61
+ test('returns null for labels that do not match any rule', () => {
62
+ expect(resolveCodeGroupIcon('mystery')).toBeNull();
63
+ expect(resolveCodeGroupIcon('totally-fake-name')).toBeNull();
64
+ expect(resolveCodeGroupIcon('')).toBeNull();
65
+ expect(resolveCodeGroupIcon(' ')).toBeNull();
66
+ });
67
+
68
+ test('does not match Object.prototype keys via the `in` operator', () => {
69
+ // `'constructor' in {}` is true because of the prototype chain; using
70
+ // Object.hasOwn (instead of `in`) prevents the resolver from returning
71
+ // prototype values for crafted labels.
72
+ expect(resolveCodeGroupIcon('constructor')).toBeNull();
73
+ expect(resolveCodeGroupIcon('toString')).toBeNull();
74
+ expect(resolveCodeGroupIcon('hasOwnProperty')).toBeNull();
75
+ expect(resolveCodeGroupIcon('valueOf')).toBeNull();
76
+ expect(resolveCodeGroupIcon('__proto__')).toBeNull();
77
+ });
78
+ });