@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.
- package/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { BookData, BookChapterData, getBookDirPath } from '@/lib/markdown';
|
|
2
3
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
3
|
-
import
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
18
|
+
// `slug` is the public-relative directory used by rehype-image-metadata to
|
|
19
|
+
// resolve ``-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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
208
|
+
{resolveCommentable(post.commentable, commentCategory) && (
|
|
209
|
+
<Comments slug={commentSlug} postUrl={postUrl} />
|
|
210
|
+
)}
|
|
169
211
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
212
|
+
{post.externalLinks && post.externalLinks.length > 0 && (
|
|
213
|
+
<ExternalLinks links={post.externalLinks} />
|
|
214
|
+
)}
|
|
173
215
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
216
|
+
<Suspense fallback={null}>
|
|
217
|
+
<PostNavigation prev={prevPost ?? null} next={nextPost ?? null} currentSlug={post.slug} collectionContexts={collectionContexts} />
|
|
218
|
+
</Suspense>
|
|
177
219
|
|
|
178
|
-
|
|
179
|
-
|
|
220
|
+
<RelatedPosts posts={relatedPosts || []} />
|
|
221
|
+
</article>
|
|
222
|
+
</div>
|
|
180
223
|
</div>
|
|
181
|
-
</
|
|
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
|
+
});
|