@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import type { PostData, CollectionContext, Heading } from '@/lib/markdown';
|
|
7
|
+
import { useLanguage } from './LanguageProvider';
|
|
8
|
+
import { useImmersiveReading } from './ImmersiveReadingProvider';
|
|
9
|
+
import { useSidebarAutoScroll } from '@/hooks/useSidebarAutoScroll';
|
|
10
|
+
import InlineBookToc from './InlineBookToc';
|
|
11
|
+
import { getPostUrl, getPostUrlInCollection, getSeriesListUrl, getSeriesUrl } from '@/lib/urls';
|
|
12
|
+
|
|
13
|
+
// Dedicated TOC sidebar for the immersive reader on a series post. Visually
|
|
14
|
+
// mirrors BookSidebar's `mode="fill"` shape (clean numbered list, left-
|
|
15
|
+
// border accent on the current item, inlined headings under the current
|
|
16
|
+
// post, footer pointing at the listing page) rather than the page-mode
|
|
17
|
+
// SeriesList card — see PR #95 review feedback.
|
|
18
|
+
|
|
19
|
+
interface ImmersiveSeriesSidebarProps {
|
|
20
|
+
seriesSlug: string;
|
|
21
|
+
seriesTitle: string;
|
|
22
|
+
posts: PostData[];
|
|
23
|
+
/** When the post is in a collection, the sidebar can render in that
|
|
24
|
+
* collection's scope by appending `?collection=<slug>` to the URL. Same
|
|
25
|
+
* resolution logic as SeriesList. */
|
|
26
|
+
collectionContexts?: CollectionContext[];
|
|
27
|
+
currentSlug: string;
|
|
28
|
+
/** h2/h3 headings for the current post — rendered as an inline TOC under
|
|
29
|
+
* the current post's row via the shared InlineBookToc component. */
|
|
30
|
+
headings?: Heading[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function ImmersiveSeriesSidebar({
|
|
34
|
+
seriesSlug,
|
|
35
|
+
seriesTitle,
|
|
36
|
+
posts,
|
|
37
|
+
collectionContexts,
|
|
38
|
+
currentSlug,
|
|
39
|
+
headings = [],
|
|
40
|
+
}: ImmersiveSeriesSidebarProps) {
|
|
41
|
+
const { t } = useLanguage();
|
|
42
|
+
const { enabled: immersiveEnabled } = useImmersiveReading();
|
|
43
|
+
const searchParams = useSearchParams();
|
|
44
|
+
const collectionParam = searchParams.get('collection');
|
|
45
|
+
const activeCollection = collectionParam
|
|
46
|
+
? (collectionContexts ?? []).find(c => c.slug === collectionParam) ?? null
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
const effectiveSlug = activeCollection?.slug ?? seriesSlug;
|
|
50
|
+
const effectiveTitle = activeCollection?.title ?? seriesTitle;
|
|
51
|
+
const effectivePosts = activeCollection?.posts ?? posts;
|
|
52
|
+
const isCollectionContext = !!activeCollection;
|
|
53
|
+
|
|
54
|
+
// Collections mix posts from different layout segments (`/posts/[slug]` vs
|
|
55
|
+
// `/[series]/[slug]`). When clicking across that boundary, the
|
|
56
|
+
// ImmersiveReadingProvider remounts with `enabled=false` and the overlay
|
|
57
|
+
// closes. Appending `?immersive=1` lets the destination layout's
|
|
58
|
+
// ImmersiveReadingFlagHandler re-enter the reader and strip the flag.
|
|
59
|
+
const postHref = (post: PostData) => {
|
|
60
|
+
const base = activeCollection
|
|
61
|
+
? getPostUrlInCollection(post, activeCollection.slug)
|
|
62
|
+
: getPostUrl(post);
|
|
63
|
+
if (!immersiveEnabled) return base;
|
|
64
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
65
|
+
return `${base}${sep}immersive=1`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const currentIndex = effectivePosts.findIndex(p => p.slug === currentSlug);
|
|
69
|
+
const currentItemRef = useRef<HTMLLIElement>(null);
|
|
70
|
+
const sidebarRef = useRef<HTMLElement>(null);
|
|
71
|
+
useSidebarAutoScroll(sidebarRef, currentItemRef, currentSlug);
|
|
72
|
+
|
|
73
|
+
if (effectivePosts.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<aside
|
|
77
|
+
ref={sidebarRef}
|
|
78
|
+
className="block w-full h-full overflow-y-auto px-4 py-6 scrollbar-hide hover:scrollbar-thin"
|
|
79
|
+
>
|
|
80
|
+
{/* Header — series / collection label + post count + title link */}
|
|
81
|
+
<div className="mb-6 pb-4 border-b border-muted/10">
|
|
82
|
+
<div className="flex items-center justify-between mb-2">
|
|
83
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent">
|
|
84
|
+
{isCollectionContext ? t('collection') : t('series')}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="text-xs font-mono text-muted whitespace-nowrap">
|
|
87
|
+
{currentIndex + 1}/{effectivePosts.length}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
<Link href={getSeriesUrl(effectiveSlug)} className="group block no-underline">
|
|
91
|
+
<h3 className="font-serif font-bold text-heading text-lg leading-snug group-hover:text-accent transition-colors">
|
|
92
|
+
{effectiveTitle}
|
|
93
|
+
</h3>
|
|
94
|
+
</Link>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Posts list — flat, current with left-border + accent (same treatment
|
|
98
|
+
as BookSidebar's chapter link). Past posts dimmed less than future
|
|
99
|
+
posts, also matching BookSidebar. */}
|
|
100
|
+
<nav aria-label={isCollectionContext ? t('collection') : t('series')}>
|
|
101
|
+
<ul className="space-y-1">
|
|
102
|
+
{effectivePosts.map((post, index) => {
|
|
103
|
+
const isCurrent = post.slug === currentSlug;
|
|
104
|
+
const isPast = index < currentIndex;
|
|
105
|
+
return (
|
|
106
|
+
<li key={post.slug} ref={isCurrent ? currentItemRef : undefined}>
|
|
107
|
+
<Link
|
|
108
|
+
href={postHref(post)}
|
|
109
|
+
className={`block py-2 px-3 rounded-lg text-sm no-underline transition-all duration-200 ${
|
|
110
|
+
isCurrent
|
|
111
|
+
? 'bg-accent/10 text-accent font-semibold border-l-2 border-accent'
|
|
112
|
+
: isPast
|
|
113
|
+
? 'text-foreground/70 hover:text-foreground hover:bg-muted/5'
|
|
114
|
+
: 'text-muted hover:text-foreground hover:bg-muted/5'
|
|
115
|
+
}`}
|
|
116
|
+
aria-current={isCurrent ? 'page' : undefined}
|
|
117
|
+
>
|
|
118
|
+
{post.title}
|
|
119
|
+
</Link>
|
|
120
|
+
{isCurrent && <InlineBookToc headings={headings} />}
|
|
121
|
+
</li>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</ul>
|
|
125
|
+
</nav>
|
|
126
|
+
|
|
127
|
+
{/* Footer — points at the series listing (not back to the current
|
|
128
|
+
series detail, which the header already links to). Matches
|
|
129
|
+
BookSidebar's "All Books" footer pattern. */}
|
|
130
|
+
<div className="mt-6 pt-4 border-t border-muted/10">
|
|
131
|
+
<Link
|
|
132
|
+
href={getSeriesListUrl()}
|
|
133
|
+
className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
|
|
134
|
+
>
|
|
135
|
+
{t('all_series')}
|
|
136
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
137
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
138
|
+
</svg>
|
|
139
|
+
</Link>
|
|
140
|
+
</div>
|
|
141
|
+
</aside>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
|
|
4
|
+
import { useLanguage } from '@/components/LanguageProvider';
|
|
5
|
+
|
|
6
|
+
export default function ImmersiveToggleButton() {
|
|
7
|
+
const { enabled, toggle } = useImmersiveReading();
|
|
8
|
+
const { t } = useLanguage();
|
|
9
|
+
// The button is the "enter" affordance; in immersive mode the top bar's
|
|
10
|
+
// exit (✕) is the only way out, so the inline button hides to avoid
|
|
11
|
+
// duplicating the exit and reading "Exit reading mode" next to it. Owning
|
|
12
|
+
// the visibility here means callers (PostLayout's article header, etc.)
|
|
13
|
+
// don't need to gate it with `{!enabled && ...}` separately.
|
|
14
|
+
if (enabled) return null;
|
|
15
|
+
const label = t('immersive_reading');
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={toggle}
|
|
21
|
+
title={label}
|
|
22
|
+
aria-label={label}
|
|
23
|
+
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-sans text-muted hover:text-accent hover:bg-muted/10 transition-colors border border-transparent hover:border-muted/20 select-none"
|
|
24
|
+
>
|
|
25
|
+
<svg
|
|
26
|
+
width="14"
|
|
27
|
+
height="14"
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
fill="none"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeWidth="2"
|
|
32
|
+
strokeLinecap="round"
|
|
33
|
+
strokeLinejoin="round"
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
>
|
|
36
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
37
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
38
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
39
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
40
|
+
<path d="M8 12h8" />
|
|
41
|
+
</svg>
|
|
42
|
+
<span className="hidden sm:inline">{label}</span>
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import MarkdownRenderer from "./MarkdownRenderer";
|
|
4
|
+
import { renderAsync } from "@/test-utils/render";
|
|
4
5
|
|
|
5
6
|
describe("MarkdownRenderer", () => {
|
|
6
7
|
describe("image rendering", () => {
|
|
@@ -12,6 +13,9 @@ describe("MarkdownRenderer", () => {
|
|
|
12
13
|
expect(html).toContain('height="900"');
|
|
13
14
|
// style override ensures the image renders at its natural size
|
|
14
15
|
expect(html).toContain('width:100%');
|
|
16
|
+
// fetchpriority="low" prevents React 19 from auto-preloading local
|
|
17
|
+
// markdown images as LCP candidates (matches the external-image fix)
|
|
18
|
+
expect(html).toContain('fetchPriority="low"');
|
|
15
19
|
});
|
|
16
20
|
|
|
17
21
|
test("uses plain img for external images", () => {
|
|
@@ -42,7 +46,7 @@ describe("MarkdownRenderer", () => {
|
|
|
42
46
|
});
|
|
43
47
|
});
|
|
44
48
|
|
|
45
|
-
test("adds horizontal overflow containment while preserving code scrolling", () => {
|
|
49
|
+
test("adds horizontal overflow containment while preserving code scrolling", async () => {
|
|
46
50
|
const content = [
|
|
47
51
|
"## Example",
|
|
48
52
|
"",
|
|
@@ -51,16 +55,22 @@ describe("MarkdownRenderer", () => {
|
|
|
51
55
|
"```",
|
|
52
56
|
].join("\n");
|
|
53
57
|
|
|
54
|
-
const html =
|
|
58
|
+
const html = await renderAsync(<MarkdownRenderer content={content} />);
|
|
55
59
|
|
|
56
60
|
expect(html).toContain("overflow-x-hidden");
|
|
57
61
|
expect(html).toContain("not-prose w-full min-w-0 max-w-full");
|
|
58
62
|
expect(html).toContain("overflow-x-auto");
|
|
63
|
+
// Shiki rendered the block — ensure the highlighted shell pass produced output.
|
|
64
|
+
expect(html).toContain('class="shiki');
|
|
59
65
|
});
|
|
60
66
|
|
|
61
|
-
test("wraps content in
|
|
67
|
+
test("wraps content in ArticleCopyCleaner so paste output is stripped of per-paragraph backgrounds", () => {
|
|
62
68
|
const content = "Hello world";
|
|
63
69
|
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
64
|
-
|
|
70
|
+
// The cleaner renders a bare wrapper div; the page background lives on body now,
|
|
71
|
+
// so the article HTML must not paint its own background (which is what caused
|
|
72
|
+
// Chromium's clipboard serializer to inline `background-color` on every <p>).
|
|
73
|
+
expect(html).not.toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
|
|
74
|
+
expect(html).toContain('<p class="mb-4 leading-relaxed text-foreground">Hello world</p>');
|
|
65
75
|
});
|
|
66
76
|
});
|
|
@@ -1,33 +1,84 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import ReactMarkdown, { Components, ExtraProps } from 'react-markdown';
|
|
2
3
|
import RssFeedWidget from '@/components/RssFeedWidget';
|
|
3
4
|
import Mermaid from '@/components/Mermaid';
|
|
4
5
|
import CodeBlock from '@/components/CodeBlock';
|
|
6
|
+
import CodeGroup from '@/components/CodeGroup';
|
|
7
|
+
import GithubAlert from '@/components/GithubAlert';
|
|
5
8
|
import KatexStyles from '@/components/KatexStyles';
|
|
9
|
+
import ExternalLinkIcon from '@/components/ExternalLinkIcon';
|
|
10
|
+
import ArticleCopyCleaner from '@/components/ArticleCopyCleaner';
|
|
6
11
|
import remarkGfm from 'remark-gfm';
|
|
12
|
+
import remarkDirective from 'remark-directive';
|
|
13
|
+
import remarkCodeGroup from '@/lib/remark-code-group';
|
|
14
|
+
import remarkGithubAlerts from '@/lib/remark-github-alerts';
|
|
15
|
+
import remarkVuepressContainers, { normalizeVuepressContainerSyntax } from '@/lib/remark-vuepress-containers';
|
|
16
|
+
import { normalizeVuepressBlockMath } from '@/lib/normalize-vuepress-math';
|
|
17
|
+
import remarkBookChapterLinks, { type BookChapterLinksOptions } from '@/lib/remark-book-chapter-links';
|
|
7
18
|
import rehypeRaw from 'rehype-raw';
|
|
8
19
|
import remarkMath from 'remark-math';
|
|
9
20
|
import rehypeKatex from 'rehype-katex';
|
|
10
21
|
import rehypeSlug from 'rehype-slug';
|
|
11
22
|
import rehypeImageMetadata from '@/lib/rehype-image-metadata';
|
|
23
|
+
import rehypeFenceMeta from '@/lib/rehype-fence-meta';
|
|
12
24
|
import { siteConfig } from '../../site.config';
|
|
13
25
|
import remarkWikilinks from '@/lib/remark-wikilinks';
|
|
14
26
|
import ExportedImage from 'next-image-export-optimizer';
|
|
15
27
|
import { PluggableList } from 'unified';
|
|
16
28
|
import type { SlugRegistryEntry } from '@/lib/markdown';
|
|
17
29
|
import { shouldBypassImageOptimization } from '@/lib/image-utils';
|
|
30
|
+
import { parseFenceMeta } from '@/lib/shiki';
|
|
31
|
+
import { isExternalUrl } from '@/lib/urls';
|
|
18
32
|
|
|
19
33
|
|
|
34
|
+
// Flatten an arbitrary React children tree to its text content. Used by the
|
|
35
|
+
// raw-HTML <mermaid> handler below — react-markdown hands us the mermaid
|
|
36
|
+
// source as a tree of text nodes (possibly nested through whitespace-only
|
|
37
|
+
// wrappers) and the Mermaid component expects a single string.
|
|
38
|
+
function flattenChildrenToText(node: React.ReactNode): string {
|
|
39
|
+
if (node == null || typeof node === 'boolean') return '';
|
|
40
|
+
if (typeof node === 'string') return node;
|
|
41
|
+
if (typeof node === 'number') return String(node);
|
|
42
|
+
if (Array.isArray(node)) return node.map(flattenChildrenToText).join('');
|
|
43
|
+
if (React.isValidElement(node)) {
|
|
44
|
+
const children = (node.props as { children?: React.ReactNode }).children;
|
|
45
|
+
return flattenChildrenToText(children);
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
interface MarkdownRendererProps {
|
|
21
51
|
content: string;
|
|
22
52
|
latex?: boolean;
|
|
23
53
|
slug?: string;
|
|
24
54
|
slugRegistry?: Map<string, SlugRegistryEntry>;
|
|
55
|
+
/**
|
|
56
|
+
* Set when rendering a book chapter. Enables inter-chapter `.md` link
|
|
57
|
+
* rewriting and `:::container` → GitHub Alert conversion (the latter runs
|
|
58
|
+
* for everyone, but the link rewriter needs source-path context).
|
|
59
|
+
*/
|
|
60
|
+
bookContext?: BookChapterLinksOptions;
|
|
25
61
|
}
|
|
26
62
|
|
|
27
|
-
export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry }: MarkdownRendererProps) {
|
|
28
|
-
|
|
63
|
+
export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry, bookContext }: MarkdownRendererProps) {
|
|
64
|
+
// remark-directive must precede remark-code-group AND remark-vuepress-containers
|
|
65
|
+
// so they see parsed containerDirective nodes. Order vs remark-gfm doesn't matter
|
|
66
|
+
// — they touch disjoint node types.
|
|
67
|
+
const remarkPlugins: PluggableList = [
|
|
68
|
+
remarkGfm,
|
|
69
|
+
remarkGithubAlerts,
|
|
70
|
+
remarkDirective,
|
|
71
|
+
remarkCodeGroup,
|
|
72
|
+
remarkVuepressContainers,
|
|
73
|
+
];
|
|
74
|
+
if (bookContext) {
|
|
75
|
+
remarkPlugins.push([remarkBookChapterLinks, bookContext]);
|
|
76
|
+
}
|
|
29
77
|
const cdnBaseUrl = siteConfig.images?.cdnBaseUrl ?? '';
|
|
30
|
-
|
|
78
|
+
// rehypeFenceMeta must run BEFORE rehypeRaw — rehypeRaw round-trips through HTML
|
|
79
|
+
// serialization, which drops node.data.meta (a non-HTML field). Copying meta to a
|
|
80
|
+
// real data-meta attribute first lets it survive the round trip.
|
|
81
|
+
const rehypePlugins: PluggableList = [rehypeFenceMeta, rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug, cdnBaseUrl }]];
|
|
31
82
|
|
|
32
83
|
if (slugRegistry && slugRegistry.size > 0) {
|
|
33
84
|
remarkPlugins.push([remarkWikilinks, { slugRegistry }]);
|
|
@@ -35,7 +86,15 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
35
86
|
|
|
36
87
|
if (latex) {
|
|
37
88
|
remarkPlugins.push(remarkMath);
|
|
38
|
-
|
|
89
|
+
// Silence only KaTeX's `unicodeTextInMathMode` warnings — Chinese-language
|
|
90
|
+
// books routinely write math like `$输入$` or `$h_{隐藏状态}$` and KaTeX
|
|
91
|
+
// renders the CJK characters fine; the warning is pure noise (one log per
|
|
92
|
+
// character per chapter view). A bare `strict: 'ignore'` would silence
|
|
93
|
+
// *every* KaTeX strict check including genuinely broken math, so use a
|
|
94
|
+
// predicate that targets just this transgression.
|
|
95
|
+
rehypePlugins.push([rehypeKatex, {
|
|
96
|
+
strict: (code: string) => (code === 'unicodeTextInMathMode' ? 'ignore' : 'warn'),
|
|
97
|
+
}]);
|
|
39
98
|
}
|
|
40
99
|
|
|
41
100
|
const components: Components = {
|
|
@@ -70,37 +129,68 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
70
129
|
pre: ({ children }) => <div className="not-prose w-full min-w-0 max-w-full">{children}</div>,
|
|
71
130
|
// Style links individually to avoid hover-all issue
|
|
72
131
|
a: (props) => {
|
|
73
|
-
|
|
74
|
-
const { node: _node, className, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
|
|
132
|
+
const { node, className, children, href, target, rel, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
|
|
75
133
|
// Preserve wikilink classes injected by remark-wikilinks — they have their own CSS styling
|
|
76
134
|
if (className?.includes('wikilink')) {
|
|
77
|
-
return <a {...rest} className={className}
|
|
135
|
+
return <a {...rest} href={href} target={target} rel={rel} className={className}>{children}</a>;
|
|
136
|
+
}
|
|
137
|
+
const linkClass = "text-accent no-underline hover:underline transition-colors duration-200";
|
|
138
|
+
if (isExternalUrl(href)) {
|
|
139
|
+
// Image-as-link (`[](href)`): an inline arrow after the
|
|
140
|
+
// image looks like a glyph, not a hint. The HAST `node` exposes the
|
|
141
|
+
// pre-override children so we can spot an `<img>` child reliably —
|
|
142
|
+
// by the time react-markdown passes `children` to us, our own `img`
|
|
143
|
+
// override has already replaced the raw <img> with a component.
|
|
144
|
+
const hastChildren = (node && 'children' in node) ? node.children : [];
|
|
145
|
+
const isImageLink = hastChildren.length === 1 && 'tagName' in hastChildren[0] && hastChildren[0].tagName === 'img';
|
|
146
|
+
return (
|
|
147
|
+
<a
|
|
148
|
+
{...rest}
|
|
149
|
+
href={href}
|
|
150
|
+
target={target ?? '_blank'}
|
|
151
|
+
rel={rel ?? 'noopener noreferrer'}
|
|
152
|
+
className={linkClass}
|
|
153
|
+
>
|
|
154
|
+
{children}
|
|
155
|
+
{!isImageLink && <ExternalLinkIcon />}
|
|
156
|
+
</a>
|
|
157
|
+
);
|
|
78
158
|
}
|
|
79
|
-
return <a {...rest}
|
|
159
|
+
return <a {...rest} href={href} target={target} rel={rel} className={linkClass}>{children}</a>;
|
|
80
160
|
},
|
|
81
161
|
// Custom code renderer: handles 'mermaid' blocks and syntax highlighting
|
|
82
162
|
code(props: React.ClassAttributes<HTMLElement> & React.HTMLAttributes<HTMLElement> & ExtraProps) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
163
|
+
const { className, children } = props;
|
|
164
|
+
// [^\s]+ rather than \w+ so fences like ```c++ or ```objective-c++ are detected
|
|
165
|
+
// as `c++` / `objective-c++` and not truncated to `c` at the punctuation boundary.
|
|
166
|
+
const match = /language-([^\s]+)/.exec(className || '');
|
|
86
167
|
const language = match ? match[1] : '';
|
|
87
168
|
const isMultiLine = String(children).includes('\n');
|
|
88
|
-
|
|
89
|
-
// In react-markdown v10, 'inline' prop is removed.
|
|
169
|
+
|
|
170
|
+
// In react-markdown v10, 'inline' prop is removed.
|
|
90
171
|
// We use className presence (e.g. language-js) or newline presence to detect code blocks.
|
|
91
172
|
if (match || isMultiLine) {
|
|
92
173
|
if (language === 'mermaid') {
|
|
93
174
|
return <Mermaid chart={String(children).replace(/\n$/, '')} />;
|
|
94
175
|
}
|
|
176
|
+
// react-markdown v10 strips node.data before invoking overrides, so the
|
|
177
|
+
// fence meta is surfaced as a real `data-meta` attribute by rehypeFenceMeta.
|
|
178
|
+
const meta = (props as unknown as Record<string, unknown>)['data-meta'];
|
|
179
|
+
const parsedMeta = parseFenceMeta(typeof meta === 'string' ? meta : undefined);
|
|
95
180
|
return (
|
|
96
|
-
<CodeBlock
|
|
181
|
+
<CodeBlock
|
|
182
|
+
language={language}
|
|
183
|
+
title={parsedMeta.title}
|
|
184
|
+
showLineNumbers={parsedMeta.showLineNumbers}
|
|
185
|
+
highlightLines={parsedMeta.highlightLines}
|
|
186
|
+
>
|
|
97
187
|
{String(children).replace(/\n$/, '')}
|
|
98
188
|
</CodeBlock>
|
|
99
189
|
);
|
|
100
190
|
}
|
|
101
191
|
|
|
102
192
|
return (
|
|
103
|
-
<code className={className}
|
|
193
|
+
<code className={className}>
|
|
104
194
|
{children}
|
|
105
195
|
</code>
|
|
106
196
|
);
|
|
@@ -134,11 +224,36 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
134
224
|
// In development mode, use unoptimized images since WebP versions don't exist yet
|
|
135
225
|
img: (props: React.ClassAttributes<HTMLImageElement> & React.ImgHTMLAttributes<HTMLImageElement> & ExtraProps) => {
|
|
136
226
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
137
|
-
const { src, alt, width, height, node: _node, ...rest } = props;
|
|
227
|
+
const { src, alt, width, height, node: _node, style, ...rest } = props;
|
|
138
228
|
const isDev = process.env.NODE_ENV === 'development';
|
|
139
229
|
const imageSrc = src as string;
|
|
140
230
|
const isExternal = imageSrc?.startsWith('http') || imageSrc?.startsWith('//');
|
|
141
231
|
|
|
232
|
+
// Author-supplied inline `style` is a strong signal the <img> came from
|
|
233
|
+
// raw HTML inside the markdown (typically inline icons like social-media
|
|
234
|
+
// badges) rather than from a markdown ``. Markdown images
|
|
235
|
+
// never carry a style attribute. Preserve the author's styling and
|
|
236
|
+
// skip optimization for these — wrapping a 22px icon in <ExportedImage>
|
|
237
|
+
// strips the style and renders it at its natural 500px size.
|
|
238
|
+
if (style) {
|
|
239
|
+
// width / height were destructured out of `rest` above, so re-apply
|
|
240
|
+
// them here. Mixed author markup like `<img src="..." width="120"
|
|
241
|
+
// style="border-radius:4px">` should keep its explicit sizing rather
|
|
242
|
+
// than render at the SVG's natural dimensions.
|
|
243
|
+
return (
|
|
244
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
245
|
+
<img
|
|
246
|
+
src={imageSrc}
|
|
247
|
+
alt={alt || ''}
|
|
248
|
+
width={width}
|
|
249
|
+
height={height}
|
|
250
|
+
style={style}
|
|
251
|
+
{...rest}
|
|
252
|
+
fetchPriority="low"
|
|
253
|
+
/>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
142
257
|
if (!isExternal) {
|
|
143
258
|
const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
|
|
144
259
|
return (
|
|
@@ -152,6 +267,7 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
152
267
|
unoptimized={isDev || shouldBypassOptimization}
|
|
153
268
|
placeholder={shouldBypassOptimization ? 'empty' : 'blur'}
|
|
154
269
|
style={(!width || !height) ? { width: '100%', height: 'auto' } : undefined}
|
|
270
|
+
fetchPriority="low"
|
|
155
271
|
/>
|
|
156
272
|
);
|
|
157
273
|
}
|
|
@@ -160,16 +276,50 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
160
276
|
},
|
|
161
277
|
};
|
|
162
278
|
|
|
163
|
-
// Merge custom HTML elements not in the Components type (e.g. web components used in MDX
|
|
164
|
-
//
|
|
165
|
-
|
|
279
|
+
// Merge custom HTML elements not in the Components type (e.g. web components used in MDX,
|
|
280
|
+
// and the synthetic <code-group> / <github-alert> tagNames emitted by our remark plugins).
|
|
281
|
+
//
|
|
282
|
+
// VuePress component pass-throughs: imported VuePress books may use Vue
|
|
283
|
+
// components like <Swiper>/<Slide> (image carousel), <ClientOnly>, <HomeHero>,
|
|
284
|
+
// <ChatDemo>, <GlobalTOC>. hast/React lowercases these tags, and without a
|
|
285
|
+
// handler React logs "The tag <swiper> is unrecognized in this browser". Map
|
|
286
|
+
// each one to a passive renderer so the warnings go away and inner content
|
|
287
|
+
// (where it makes sense) still appears as a graceful degradation.
|
|
288
|
+
const allComponents = {
|
|
289
|
+
...components,
|
|
290
|
+
'rss-feed': () => <RssFeedWidget />,
|
|
291
|
+
'code-group': CodeGroup,
|
|
292
|
+
'github-alert': GithubAlert,
|
|
293
|
+
swiper: ({ children }: { children?: React.ReactNode }) => <div className="my-6 space-y-4">{children}</div>,
|
|
294
|
+
slide: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
|
295
|
+
clientonly: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
296
|
+
globaltoc: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
297
|
+
homehero: () => null,
|
|
298
|
+
chatdemo: () => null,
|
|
299
|
+
// <mermaid>...graph syntax...</mermaid> is the VuePress inline form. We
|
|
300
|
+
// already handle ```mermaid fenced blocks via the `code` renderer above;
|
|
301
|
+
// route the raw-HTML form to the same Mermaid component by flattening
|
|
302
|
+
// the children to a string.
|
|
303
|
+
mermaid: ({ children }: { children?: React.ReactNode }) => (
|
|
304
|
+
<Mermaid chart={flattenChildrenToText(children).trim()} />
|
|
305
|
+
),
|
|
306
|
+
// <GitHubWrapper>...</GitHubWrapper> wraps GitHub project links / cards
|
|
307
|
+
// in the fenix VuePress book. Pass children through unchanged — they're
|
|
308
|
+
// usually a paragraph or an <a>/<img> the author wants to display.
|
|
309
|
+
githubwrapper: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
310
|
+
// <words type='span' chapter='/' /> is a VuePress word-count widget that
|
|
311
|
+
// we can't reproduce without the upstream counter. Render nothing — the
|
|
312
|
+
// surrounding prose ("全文合计 X 字") degrades to "全文合计 字".
|
|
313
|
+
words: () => null,
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
315
|
+
} as any;
|
|
166
316
|
|
|
167
317
|
return (
|
|
168
318
|
<>
|
|
169
319
|
{latex && <KatexStyles />}
|
|
170
|
-
<
|
|
320
|
+
<ArticleCopyCleaner>
|
|
171
321
|
<div className="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
|
|
172
|
-
prose-headings:font-serif prose-headings:text-heading
|
|
322
|
+
prose-headings:font-serif prose-headings:text-heading
|
|
173
323
|
prose-p:text-foreground prose-p:leading-loose
|
|
174
324
|
prose-strong:text-heading prose-strong:font-semibold
|
|
175
325
|
prose-code:bg-muted/15 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:border prose-code:border-muted/20 prose-code:text-[0.9em] prose-code:font-medium
|
|
@@ -182,10 +332,12 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
182
332
|
rehypePlugins={rehypePlugins}
|
|
183
333
|
components={allComponents}
|
|
184
334
|
>
|
|
185
|
-
{
|
|
335
|
+
{latex
|
|
336
|
+
? normalizeVuepressBlockMath(normalizeVuepressContainerSyntax(content))
|
|
337
|
+
: normalizeVuepressContainerSyntax(content)}
|
|
186
338
|
</ReactMarkdown>
|
|
187
339
|
</div>
|
|
188
|
-
</
|
|
340
|
+
</ArticleCopyCleaner>
|
|
189
341
|
</>
|
|
190
342
|
);
|
|
191
343
|
}
|
|
@@ -4,6 +4,26 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
4
4
|
import mermaid from "mermaid";
|
|
5
5
|
import { useTheme } from "next-themes";
|
|
6
6
|
|
|
7
|
+
// Mermaid bundles its own KaTeX and invokes it with `{throwOnError: true,
|
|
8
|
+
// displayMode: true, output: 'mathml'}` — no `strict` option, so KaTeX
|
|
9
|
+
// defaults to `'warn'` and floods the console with one warning per CJK
|
|
10
|
+
// character in math labels (e.g. `S["$$解码器状态:s_{t-1}$$"]`). There is
|
|
11
|
+
// no `mermaid.initialize()` setting to override this. Filter the very
|
|
12
|
+
// specific KaTeX warning template at the console layer; everything else
|
|
13
|
+
// passes through. Idempotent under HMR.
|
|
14
|
+
let consoleWarnFilterInstalled = false;
|
|
15
|
+
function installConsoleWarnFilter(): void {
|
|
16
|
+
if (consoleWarnFilterInstalled || typeof window === "undefined") return;
|
|
17
|
+
consoleWarnFilterInstalled = true;
|
|
18
|
+
const originalWarn = console.warn.bind(console);
|
|
19
|
+
console.warn = (...args: unknown[]) => {
|
|
20
|
+
if (typeof args[0] === "string" && args[0].includes("[unicodeTextInMathMode]")) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
originalWarn(...args);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
interface MermaidProps {
|
|
8
28
|
chart: string;
|
|
9
29
|
}
|
|
@@ -28,6 +48,7 @@ const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
|
|
|
28
48
|
|
|
29
49
|
useEffect(() => {
|
|
30
50
|
if (ref.current && chart && mounted) {
|
|
51
|
+
installConsoleWarnFilter();
|
|
31
52
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
|
32
53
|
const isDark = currentTheme === 'dark';
|
|
33
54
|
|
|
@@ -76,11 +97,21 @@ const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
|
|
|
76
97
|
}, [chart, theme, systemTheme, mounted]);
|
|
77
98
|
|
|
78
99
|
return (
|
|
79
|
-
<div className="my-
|
|
100
|
+
<div className="my-6 overflow-x-auto">
|
|
101
|
+
{/*
|
|
102
|
+
suppressHydrationWarning is intentional: Mermaid runs client-side
|
|
103
|
+
in `useEffect`, injects its SVG via `dangerouslySetInnerHTML`, and
|
|
104
|
+
then mutates the DOM further (adding `data-processed="true"` on
|
|
105
|
+
this wrapper). React's virtual DOM has no record of those
|
|
106
|
+
mutations, so any HMR-triggered re-render in dev flags the drift
|
|
107
|
+
as a hydration mismatch. Telling React this div is
|
|
108
|
+
intentionally-mutated terrain is the blessed escape hatch.
|
|
109
|
+
*/}
|
|
80
110
|
<div
|
|
81
111
|
className="mermaid w-full flex justify-center"
|
|
82
112
|
dangerouslySetInnerHTML={{ __html: svg }}
|
|
83
113
|
ref={ref}
|
|
114
|
+
suppressHydrationWarning
|
|
84
115
|
/>
|
|
85
116
|
</div>
|
|
86
117
|
);
|
|
@@ -91,7 +91,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
91
91
|
}, [isMenuOpen]);
|
|
92
92
|
|
|
93
93
|
return (
|
|
94
|
-
<nav
|
|
94
|
+
<nav
|
|
95
|
+
data-site-nav
|
|
96
|
+
className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
|
|
95
97
|
isScrolled
|
|
96
98
|
? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
|
|
97
99
|
: 'border-transparent bg-transparent'
|
|
@@ -61,7 +61,7 @@ export default function PostList({
|
|
|
61
61
|
<span className="shrink-0">·</span>
|
|
62
62
|
</>
|
|
63
63
|
)}
|
|
64
|
-
<span className="shrink-0 hidden sm:inline">{post.
|
|
64
|
+
<span className="shrink-0 hidden sm:inline">{post.readingMinutes} {t('reading_time')}</span>
|
|
65
65
|
<span className="shrink-0 hidden sm:inline">·</span>
|
|
66
66
|
<span className="shrink-0 whitespace-nowrap">{post.date}</span>
|
|
67
67
|
{post.draft && (
|
|
@@ -37,16 +37,24 @@ export default function PostNavigation({ prev, next, currentSlug, collectionCont
|
|
|
37
37
|
if (!effectivePrev && !effectiveNext) return null;
|
|
38
38
|
|
|
39
39
|
return (
|
|
40
|
+
// suppressHydrationWarning on locale-bound nodes is a band-aid for the
|
|
41
|
+
// known static-export + client-i18n drift: SSR renders defaultLocale,
|
|
42
|
+
// `useLanguage()` hook serves the user's saved locale on hydration. The
|
|
43
|
+
// real fix is per-locale URL routing, tracked as a separate refactor.
|
|
40
44
|
<nav
|
|
41
45
|
className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-1 sm:grid-cols-2 gap-3"
|
|
42
46
|
aria-label={t('post_navigation')}
|
|
47
|
+
suppressHydrationWarning
|
|
43
48
|
>
|
|
44
49
|
{effectivePrev && (
|
|
45
50
|
<Link
|
|
46
51
|
href={postHref(effectivePrev)}
|
|
47
52
|
className="group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline"
|
|
48
53
|
>
|
|
49
|
-
<span
|
|
54
|
+
<span
|
|
55
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5"
|
|
56
|
+
suppressHydrationWarning
|
|
57
|
+
>
|
|
50
58
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
51
59
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
52
60
|
</svg>
|
|
@@ -64,7 +72,10 @@ export default function PostNavigation({ prev, next, currentSlug, collectionCont
|
|
|
64
72
|
href={postHref(effectiveNext)}
|
|
65
73
|
className={`group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline sm:items-end sm:text-right${!effectivePrev ? ' sm:col-start-2' : ''}`}
|
|
66
74
|
>
|
|
67
|
-
<span
|
|
75
|
+
<span
|
|
76
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5"
|
|
77
|
+
suppressHydrationWarning
|
|
78
|
+
>
|
|
68
79
|
{t('next')}
|
|
69
80
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
70
81
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|