@hutusi/amytis 1.13.0 → 1.15.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +32 -0
- package/GEMINI.md +9 -1
- package/README.md +36 -2
- package/README.zh.md +36 -2
- package/TODO.md +10 -0
- package/bun.lock +123 -91
- package/content/flows/2026/03/05.md +1 -0
- package/content/flows/2026/03/07.md +2 -0
- package/content/series/modern-web-dev/index.mdx +4 -2
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ARCHITECTURE.md +30 -4
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +22 -1
- package/eslint.config.mjs +2 -0
- package/next.config.ts +2 -2
- package/package.json +27 -21
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/new-flow.ts +1 -0
- package/scripts/render-rst.py +719 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- package/src/app/all.atom/route.ts +7 -0
- package/src/app/all.xml/route.ts +7 -0
- package/src/app/archive/page.tsx +7 -4
- package/src/app/feed.atom/route.ts +2 -57
- package/src/app/feed.xml/route.ts +2 -64
- package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
- package/src/app/flows/feed.atom/route.ts +7 -0
- package/src/app/flows/feed.xml/route.ts +7 -0
- package/src/app/globals.css +165 -0
- package/src/app/page.tsx +1 -0
- package/src/app/posts/feed.atom/route.ts +9 -0
- package/src/app/posts/feed.xml/route.ts +9 -0
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/components/AuthorCard.tsx +25 -16
- package/src/components/CoverImage.tsx +5 -2
- package/src/components/FlowCalendarSidebar.tsx +1 -1
- package/src/components/FlowContent.tsx +2 -1
- package/src/components/FlowTimelineEntry.tsx +7 -1
- package/src/components/Footer.tsx +1 -1
- package/src/components/MarkdownRenderer.test.tsx +22 -0
- package/src/components/MarkdownRenderer.tsx +22 -17
- package/src/components/Navbar.tsx +1 -1
- package/src/components/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +122 -0
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +140 -2
- package/src/lib/markdown.ts +747 -214
- package/src/lib/rehype-image-metadata.ts +2 -2
- package/src/lib/rst-renderer.test.ts +355 -0
- package/src/lib/rst-renderer.ts +617 -0
- package/src/lib/rst.test.ts +140 -0
- package/src/lib/rst.ts +470 -0
- package/src/lib/series-redirects.ts +42 -0
- package/tests/e2e/navigation.test.ts +26 -0
- package/tests/integration/collections.test.ts +17 -2
- package/tests/integration/feed-utils.test.ts +65 -0
- package/tests/integration/flow-title.test.ts +53 -0
- package/tests/integration/reading-time-headings.test.ts +5 -9
- package/tests/integration/series-draft.test.ts +16 -2
- package/tests/integration/series.test.ts +93 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/unit/static-params.test.ts +140 -0
|
@@ -9,20 +9,10 @@ import Link from 'next/link';
|
|
|
9
9
|
import { t, resolveLocale } from '@/lib/i18n';
|
|
10
10
|
import { getPostUrl, getPostUrlInCollection } from '@/lib/urls';
|
|
11
11
|
import RedirectPage from '@/components/RedirectPage';
|
|
12
|
+
import { findSeriesByRedirectFrom, safeDecodeParam } from '@/lib/series-redirects';
|
|
12
13
|
|
|
13
14
|
const PAGE_SIZE = siteConfig.pagination.series;
|
|
14
15
|
|
|
15
|
-
/** Returns the series whose index.mdx lists `path` in its redirectFrom array, or null. */
|
|
16
|
-
function findSeriesByRedirectFrom(path: string) {
|
|
17
|
-
for (const seriesSlug of Object.keys(getAllSeries())) {
|
|
18
|
-
const data = getSeriesData(seriesSlug);
|
|
19
|
-
if (data?.redirectFrom?.includes(path)) {
|
|
20
|
-
return { slug: seriesSlug, data };
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
export async function generateStaticParams() {
|
|
27
17
|
const allSeries = getAllSeries();
|
|
28
18
|
const slugs = new Set(Object.keys(allSeries));
|
|
@@ -38,6 +28,14 @@ export async function generateStaticParams() {
|
|
|
38
28
|
}
|
|
39
29
|
}
|
|
40
30
|
|
|
31
|
+
// Work around Next dev static-param checks for percent-encoded Unicode paths
|
|
32
|
+
// under `output: "export"` — dev server may receive encoded forms of Unicode slugs.
|
|
33
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
34
|
+
for (const slug of [...slugs]) {
|
|
35
|
+
slugs.add(encodeURIComponent(slug));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
41
39
|
if (slugs.size === 0) return [{ slug: '_' }];
|
|
42
40
|
return Array.from(slugs).map((slug) => ({ slug }));
|
|
43
41
|
}
|
|
@@ -46,7 +44,7 @@ export const dynamicParams = false;
|
|
|
46
44
|
|
|
47
45
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
|
48
46
|
const { slug: rawSlug } = await params;
|
|
49
|
-
const slug =
|
|
47
|
+
const slug = safeDecodeParam(rawSlug);
|
|
50
48
|
const currentPath = `/series/${slug}`;
|
|
51
49
|
|
|
52
50
|
const redirect = findSeriesByRedirectFrom(currentPath);
|
|
@@ -98,7 +96,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
98
96
|
|
|
99
97
|
export default async function SeriesPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
100
98
|
const { slug: rawSlug } = await params;
|
|
101
|
-
const slug =
|
|
99
|
+
const slug = safeDecodeParam(rawSlug);
|
|
102
100
|
const currentPath = `/series/${slug}`;
|
|
103
101
|
|
|
104
102
|
const redirect = findSeriesByRedirectFrom(currentPath);
|
package/src/app/series/page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAllSeries, getSeriesData, resolveSeriesAuthors } from '@/lib/markdown';
|
|
1
|
+
import { getAllSeries, getSeriesData, getSeriesLatestPostDate, resolveSeriesAuthors } from '@/lib/markdown';
|
|
2
2
|
import Link from 'next/link';
|
|
3
3
|
import { siteConfig } from '../../../site.config';
|
|
4
4
|
import { Metadata } from 'next';
|
|
@@ -20,8 +20,8 @@ export default function SeriesIndexPage() {
|
|
|
20
20
|
|
|
21
21
|
// Sort by most recent post date (active series first)
|
|
22
22
|
const seriesSlugs = Object.keys(allSeries).sort((a, b) => {
|
|
23
|
-
const latestA =
|
|
24
|
-
const latestB =
|
|
23
|
+
const latestA = getSeriesLatestPostDate(a);
|
|
24
|
+
const latestB = getSeriesLatestPostDate(b);
|
|
25
25
|
return latestB.localeCompare(latestA);
|
|
26
26
|
});
|
|
27
27
|
|
|
@@ -3,6 +3,7 @@ import ExportedImage from 'next-image-export-optimizer';
|
|
|
3
3
|
import { getAuthorSlug } from '@/lib/markdown';
|
|
4
4
|
import { siteConfig } from '../../site.config';
|
|
5
5
|
import { t } from '@/lib/i18n';
|
|
6
|
+
import { shouldBypassImageOptimization } from '@/lib/image-utils';
|
|
6
7
|
|
|
7
8
|
const isDev = process.env.NODE_ENV === 'development';
|
|
8
9
|
const isExternal = (src: string) => src.startsWith('http') || src.startsWith('//');
|
|
@@ -16,6 +17,9 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
|
|
|
16
17
|
const slug = getAuthorSlug(author);
|
|
17
18
|
const profile = siteConfig.authors?.[author];
|
|
18
19
|
const hasSocial = profile?.social && profile.social.length > 0;
|
|
20
|
+
const avatarBypassOptimization = Boolean(
|
|
21
|
+
profile?.avatar && (isDev || isExternal(profile.avatar) || shouldBypassImageOptimization(profile.avatar))
|
|
22
|
+
);
|
|
19
23
|
|
|
20
24
|
return (
|
|
21
25
|
<div
|
|
@@ -31,7 +35,8 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
|
|
|
31
35
|
width={56}
|
|
32
36
|
height={56}
|
|
33
37
|
className="w-14 h-14 rounded-full object-cover flex-shrink-0 ring-2 ring-muted/20"
|
|
34
|
-
unoptimized={
|
|
38
|
+
unoptimized={avatarBypassOptimization}
|
|
39
|
+
placeholder={avatarBypassOptimization ? 'empty' : 'blur'}
|
|
35
40
|
/>
|
|
36
41
|
) : (
|
|
37
42
|
<div className="w-14 h-14 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-2xl select-none">
|
|
@@ -60,21 +65,25 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
|
|
|
60
65
|
{/* Right — social images (e.g. QR codes) */}
|
|
61
66
|
{hasSocial && (
|
|
62
67
|
<div className="flex justify-center gap-5 flex-shrink-0 border-t border-muted/15 pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-6 sm:justify-start">
|
|
63
|
-
{profile.social!.map((item, index) =>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
{profile.social!.map((item, index) => {
|
|
69
|
+
const socialImageBypassOptimization = isDev || isExternal(item.image) || shouldBypassImageOptimization(item.image);
|
|
70
|
+
return (
|
|
71
|
+
<figure key={index} className="flex flex-col items-center gap-1.5">
|
|
72
|
+
<ExportedImage
|
|
73
|
+
src={item.image}
|
|
74
|
+
alt={item.description}
|
|
75
|
+
width={72}
|
|
76
|
+
height={72}
|
|
77
|
+
className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
|
|
78
|
+
unoptimized={socialImageBypassOptimization}
|
|
79
|
+
placeholder={socialImageBypassOptimization ? 'empty' : 'blur'}
|
|
80
|
+
/>
|
|
81
|
+
<figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
|
|
82
|
+
{item.description}
|
|
83
|
+
</figcaption>
|
|
84
|
+
</figure>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
78
87
|
</div>
|
|
79
88
|
)}
|
|
80
89
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import ExportedImage from 'next-image-export-optimizer';
|
|
3
3
|
import { siteConfig } from '../../site.config';
|
|
4
|
-
import { getCdnImageUrl } from '@/lib/image-utils';
|
|
4
|
+
import { getCdnImageUrl, shouldBypassImageOptimization } from '@/lib/image-utils';
|
|
5
5
|
|
|
6
6
|
// Each palette defines a gradient background and text color for light/dark modes
|
|
7
7
|
const palettes = [
|
|
@@ -88,6 +88,8 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
|
|
|
88
88
|
const imageSrc = getCdnImageUrl(src!, cdnBaseUrl);
|
|
89
89
|
const isCdn = cdnBaseUrl && imageSrc !== src;
|
|
90
90
|
const isDev = process.env.NODE_ENV === 'development';
|
|
91
|
+
const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
|
|
92
|
+
const useBlurPlaceholder = !(isDev || !!isCdn || shouldBypassOptimization);
|
|
91
93
|
|
|
92
94
|
return (
|
|
93
95
|
<ExportedImage
|
|
@@ -96,7 +98,8 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
|
|
|
96
98
|
className={className}
|
|
97
99
|
fill
|
|
98
100
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
99
|
-
unoptimized={
|
|
101
|
+
unoptimized={!useBlurPlaceholder}
|
|
102
|
+
placeholder={useBlurPlaceholder ? 'blur' : 'empty'}
|
|
100
103
|
loading={loading}
|
|
101
104
|
/>
|
|
102
105
|
);
|
|
@@ -79,7 +79,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
79
79
|
}, [firstDay, daysInMonth]);
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
|
-
<aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)]">
|
|
82
|
+
<aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] select-none">
|
|
83
83
|
{breadcrumb && <div className="mb-4">{breadcrumb}</div>}
|
|
84
84
|
<div className="border border-muted/20 rounded-lg p-4">
|
|
85
85
|
{/* Month navigation */}
|
|
@@ -9,7 +9,7 @@ import Pagination from '@/components/Pagination';
|
|
|
9
9
|
interface FlowItem {
|
|
10
10
|
slug: string;
|
|
11
11
|
date: string;
|
|
12
|
-
title
|
|
12
|
+
title?: string;
|
|
13
13
|
excerpt: string;
|
|
14
14
|
tags: string[];
|
|
15
15
|
}
|
|
@@ -102,6 +102,7 @@ export default function FlowContent({ flows, allFlows, entryDates, tags, current
|
|
|
102
102
|
<FlowTimelineEntry
|
|
103
103
|
key={flow.slug}
|
|
104
104
|
date={flow.date}
|
|
105
|
+
title={flow.title}
|
|
105
106
|
excerpt={flow.excerpt}
|
|
106
107
|
tags={flow.tags}
|
|
107
108
|
slug={flow.slug}
|
|
@@ -3,12 +3,15 @@ import Tag from './Tag';
|
|
|
3
3
|
|
|
4
4
|
interface FlowTimelineEntryProps {
|
|
5
5
|
date: string;
|
|
6
|
+
title?: string;
|
|
6
7
|
excerpt: string;
|
|
7
8
|
tags: string[];
|
|
8
9
|
slug: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export default function FlowTimelineEntry({ date, excerpt, tags, slug }: FlowTimelineEntryProps) {
|
|
12
|
+
export default function FlowTimelineEntry({ date, title, excerpt, tags, slug }: FlowTimelineEntryProps) {
|
|
13
|
+
const hasExplicitTitle = title && title !== date;
|
|
14
|
+
|
|
12
15
|
return (
|
|
13
16
|
<article className="relative pl-6 pb-8 border-l-2 border-muted/20 last:pb-0">
|
|
14
17
|
{/* Timeline dot */}
|
|
@@ -16,6 +19,9 @@ export default function FlowTimelineEntry({ date, excerpt, tags, slug }: FlowTim
|
|
|
16
19
|
|
|
17
20
|
<Link href={`/flows/${slug}`} className="no-underline group">
|
|
18
21
|
<time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{date}</time>
|
|
22
|
+
{hasExplicitTitle && (
|
|
23
|
+
<h3 className="mt-1 text-base font-semibold text-heading group-hover:text-accent transition-colors">{title}</h3>
|
|
24
|
+
)}
|
|
19
25
|
</Link>
|
|
20
26
|
{excerpt && (
|
|
21
27
|
<p className="mt-1.5 text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
|
|
@@ -11,7 +11,7 @@ export default function Footer() {
|
|
|
11
11
|
const { t, language } = useLanguage();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<footer className="bg-muted/5 border-t border-muted/10 mt-auto">
|
|
14
|
+
<footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
|
|
15
15
|
<div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
|
|
16
16
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
|
|
17
17
|
{/* Brand */}
|
|
@@ -24,6 +24,22 @@ describe("MarkdownRenderer", () => {
|
|
|
24
24
|
// images as LCP candidates, avoiding "preloaded but not used" warnings
|
|
25
25
|
expect(html).toContain('fetchPriority="low"');
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
test("bypasses optimization for local avif images", () => {
|
|
29
|
+
const content = "";
|
|
30
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
31
|
+
expect(html).toContain('src="/images/background-new-wave.avif"');
|
|
32
|
+
expect(html).not.toContain('nextImageExportOptimizer');
|
|
33
|
+
expect(html).not.toContain('background-image:url');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("bypasses optimization for local webp images", () => {
|
|
37
|
+
const content = "";
|
|
38
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
39
|
+
expect(html).toContain('src="/images/already-optimized.webp"');
|
|
40
|
+
expect(html).not.toContain('nextImageExportOptimizer');
|
|
41
|
+
expect(html).not.toContain('background-image:url');
|
|
42
|
+
});
|
|
27
43
|
});
|
|
28
44
|
|
|
29
45
|
test("adds horizontal overflow containment while preserving code scrolling", () => {
|
|
@@ -41,4 +57,10 @@ describe("MarkdownRenderer", () => {
|
|
|
41
57
|
expect(html).toContain("not-prose w-full min-w-0 max-w-full");
|
|
42
58
|
expect(html).toContain("overflow-x-auto");
|
|
43
59
|
});
|
|
60
|
+
|
|
61
|
+
test("wraps content in a background container for copy-paste fidelity", () => {
|
|
62
|
+
const content = "Hello world";
|
|
63
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
64
|
+
expect(html).toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
|
|
65
|
+
});
|
|
44
66
|
});
|
|
@@ -14,6 +14,7 @@ import remarkWikilinks from '@/lib/remark-wikilinks';
|
|
|
14
14
|
import ExportedImage from 'next-image-export-optimizer';
|
|
15
15
|
import { PluggableList } from 'unified';
|
|
16
16
|
import type { SlugRegistryEntry } from '@/lib/markdown';
|
|
17
|
+
import { shouldBypassImageOptimization } from '@/lib/image-utils';
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
interface MarkdownRendererProps {
|
|
@@ -139,6 +140,7 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
139
140
|
const isExternal = imageSrc?.startsWith('http') || imageSrc?.startsWith('//');
|
|
140
141
|
|
|
141
142
|
if (!isExternal) {
|
|
143
|
+
const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
|
|
142
144
|
return (
|
|
143
145
|
<ExportedImage
|
|
144
146
|
src={imageSrc || ''}
|
|
@@ -147,7 +149,8 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
147
149
|
height={height ? Number(height) : 900}
|
|
148
150
|
className="max-w-full h-auto rounded-lg my-4"
|
|
149
151
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 70vw"
|
|
150
|
-
unoptimized={isDev}
|
|
152
|
+
unoptimized={isDev || shouldBypassOptimization}
|
|
153
|
+
placeholder={shouldBypassOptimization ? 'empty' : 'blur'}
|
|
151
154
|
style={(!width || !height) ? { width: '100%', height: 'auto' } : undefined}
|
|
152
155
|
/>
|
|
153
156
|
);
|
|
@@ -164,22 +167,24 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
164
167
|
return (
|
|
165
168
|
<>
|
|
166
169
|
{latex && <KatexStyles />}
|
|
167
|
-
<div className="
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
170
|
+
<div className="bg-background"> {/* Explicit background for better copy-paste fidelity */}
|
|
171
|
+
<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
|
|
173
|
+
prose-p:text-foreground prose-p:leading-loose
|
|
174
|
+
prose-strong:text-heading prose-strong:font-semibold
|
|
175
|
+
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
|
|
176
|
+
prose-code:before:content-none prose-code:after:content-none
|
|
177
|
+
prose-blockquote:italic
|
|
178
|
+
prose-th:text-heading prose-td:text-foreground
|
|
179
|
+
dark:prose-invert">
|
|
180
|
+
<ReactMarkdown
|
|
181
|
+
remarkPlugins={remarkPlugins}
|
|
182
|
+
rehypePlugins={rehypePlugins}
|
|
183
|
+
components={allComponents}
|
|
184
|
+
>
|
|
185
|
+
{content}
|
|
186
|
+
</ReactMarkdown>
|
|
187
|
+
</div>
|
|
183
188
|
</div>
|
|
184
189
|
</>
|
|
185
190
|
);
|
|
@@ -91,7 +91,7 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
|
|
|
91
91
|
}, [isMenuOpen]);
|
|
92
92
|
|
|
93
93
|
return (
|
|
94
|
-
<nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 ${
|
|
94
|
+
<nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
|
|
95
95
|
isScrolled
|
|
96
96
|
? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
|
|
97
97
|
: 'border-transparent bg-transparent'
|
|
@@ -73,7 +73,7 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
73
73
|
<aside
|
|
74
74
|
ref={sidebarRef}
|
|
75
75
|
data-testid="post-sidebar"
|
|
76
|
-
className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] overflow-y-auto pr-4 scrollbar-hide hover:scrollbar-thin"
|
|
76
|
+
className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] overflow-y-auto pr-4 scrollbar-hide hover:scrollbar-thin select-none"
|
|
77
77
|
>
|
|
78
78
|
{/* TOC — always at top */}
|
|
79
79
|
<TocPanel
|
|
@@ -6,6 +6,7 @@ import { useLanguage } from './LanguageProvider';
|
|
|
6
6
|
export interface RecentNoteItem {
|
|
7
7
|
slug: string;
|
|
8
8
|
date: string;
|
|
9
|
+
title?: string;
|
|
9
10
|
excerpt: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -36,6 +37,9 @@ export default function RecentNotesSection({ notes }: RecentNotesSectionProps) {
|
|
|
36
37
|
<div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-accent" />
|
|
37
38
|
<Link href={`/flows/${note.slug}`} className="no-underline group">
|
|
38
39
|
<time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{note.date}</time>
|
|
40
|
+
{note.title && note.title !== note.date && (
|
|
41
|
+
<h3 className="mt-0.5 text-sm font-semibold text-heading group-hover:text-accent transition-colors">{note.title}</h3>
|
|
42
|
+
)}
|
|
39
43
|
</Link>
|
|
40
44
|
{note.excerpt && (
|
|
41
45
|
<p className="mt-1.5 text-sm text-muted line-clamp-2">{note.excerpt}</p>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import RstRenderer from './RstRenderer';
|
|
4
|
+
|
|
5
|
+
describe('RstRenderer', () => {
|
|
6
|
+
test('renders pre-rendered html when available', () => {
|
|
7
|
+
const html = renderToStaticMarkup(
|
|
8
|
+
<RstRenderer
|
|
9
|
+
content="Fallback body"
|
|
10
|
+
html={
|
|
11
|
+
'<section><h2 id="intro">Intro</h2><figure class="docutils"><img src="/posts/demo/test.png" alt="Test" onerror="alert(2)" /><figcaption>Caption</figcaption></figure><aside class="admonition note"><p class="admonition-title">Note</p><p>Keep me</p></aside><p><a href="/demo" onclick="alert(3)">Link</a></p><p><a href="javascript:alert(4)">Bad link</a></p><script>alert(1)</script><iframe src="https://example.com/embed"></iframe></section>'
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
expect(html).toContain('rst-rendered');
|
|
17
|
+
expect(html).toContain('id="intro"');
|
|
18
|
+
expect(html).toContain('<figure');
|
|
19
|
+
expect(html).toContain('<figcaption>Caption</figcaption>');
|
|
20
|
+
expect(html).toContain('admonition-title');
|
|
21
|
+
expect(html).toContain('/posts/demo/test.png');
|
|
22
|
+
expect(html).toContain('href="/demo"');
|
|
23
|
+
expect(html).not.toContain('alert(1)');
|
|
24
|
+
expect(html).not.toContain('<script');
|
|
25
|
+
expect(html).not.toContain('<iframe');
|
|
26
|
+
expect(html).not.toContain('onclick');
|
|
27
|
+
expect(html).not.toContain('onerror');
|
|
28
|
+
expect(html).not.toContain('javascript:alert(4)');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('blocks data urls on images', () => {
|
|
32
|
+
const html = renderToStaticMarkup(
|
|
33
|
+
<RstRenderer
|
|
34
|
+
content="Fallback body"
|
|
35
|
+
html={'<p><img src="data:image/svg+xml,<svg onload=alert(1)>" alt="Bad" /></p>'}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(html).toContain('<img');
|
|
40
|
+
expect(html).not.toContain('data:image');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('preserves MathML elements', () => {
|
|
44
|
+
const html = renderToStaticMarkup(
|
|
45
|
+
<RstRenderer
|
|
46
|
+
content="Fallback body"
|
|
47
|
+
html={'<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>2</mn></mrow></math>'}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(html).toContain('<math');
|
|
52
|
+
expect(html).toContain('<mrow');
|
|
53
|
+
expect(html).toContain('<mi>x</mi>');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('wraps rendered rst tables with the same scroll container pattern as markdown', () => {
|
|
57
|
+
const html = renderToStaticMarkup(
|
|
58
|
+
<RstRenderer
|
|
59
|
+
content="Fallback body"
|
|
60
|
+
html={'<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(html).toContain('class="rst-table-wrapper"');
|
|
65
|
+
expect(html).toContain('<table>');
|
|
66
|
+
expect(html).toContain('<th>A</th>');
|
|
67
|
+
expect(html).toContain('<td>B</td>');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('renders converted headings, links, and code blocks through the markdown renderer', () => {
|
|
71
|
+
const html = renderToStaticMarkup(
|
|
72
|
+
<RstRenderer
|
|
73
|
+
content={[
|
|
74
|
+
'Section',
|
|
75
|
+
'-------',
|
|
76
|
+
'',
|
|
77
|
+
'Paragraph with `Example <https://example.com>`_.',
|
|
78
|
+
'',
|
|
79
|
+
'.. code-block:: ts',
|
|
80
|
+
'',
|
|
81
|
+
' export const value = 1;',
|
|
82
|
+
].join('\n')}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(html).toContain('Section');
|
|
87
|
+
expect(html).toContain('https://example.com');
|
|
88
|
+
expect(html).toContain('language-ts');
|
|
89
|
+
expect(html).toContain('<code class="language-ts"');
|
|
90
|
+
expect(html).toContain('token keyword');
|
|
91
|
+
expect(html).toContain('token number');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
2
|
+
import KatexStyles from '@/components/KatexStyles';
|
|
3
|
+
import type { SlugRegistryEntry } from '@/lib/markdown';
|
|
4
|
+
import { rstToMarkdown } from '@/lib/rst';
|
|
5
|
+
import sanitizeHtml from 'sanitize-html';
|
|
6
|
+
|
|
7
|
+
interface RstRendererProps {
|
|
8
|
+
content: string;
|
|
9
|
+
html?: string;
|
|
10
|
+
latex?: boolean;
|
|
11
|
+
slug?: string;
|
|
12
|
+
slugRegistry?: Map<string, SlugRegistryEntry>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const proseClasses = `prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
|
|
16
|
+
prose-headings:font-serif prose-headings:text-heading
|
|
17
|
+
prose-p:text-foreground prose-p:leading-loose
|
|
18
|
+
prose-strong:text-heading prose-strong:font-semibold
|
|
19
|
+
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
|
|
20
|
+
prose-code:before:content-none prose-code:after:content-none
|
|
21
|
+
prose-blockquote:italic
|
|
22
|
+
prose-th:text-heading prose-td:text-foreground
|
|
23
|
+
dark:prose-invert`;
|
|
24
|
+
|
|
25
|
+
const allowedTags = [
|
|
26
|
+
...(sanitizeHtml.defaults.allowedTags ?? []),
|
|
27
|
+
'section',
|
|
28
|
+
'img',
|
|
29
|
+
'source',
|
|
30
|
+
'figure',
|
|
31
|
+
'figcaption',
|
|
32
|
+
'aside',
|
|
33
|
+
'math',
|
|
34
|
+
'annotation',
|
|
35
|
+
'annotation-xml',
|
|
36
|
+
'maction',
|
|
37
|
+
'menclose',
|
|
38
|
+
'merror',
|
|
39
|
+
'mfenced',
|
|
40
|
+
'mfrac',
|
|
41
|
+
'mi',
|
|
42
|
+
'mmultiscripts',
|
|
43
|
+
'mn',
|
|
44
|
+
'mo',
|
|
45
|
+
'mover',
|
|
46
|
+
'mpadded',
|
|
47
|
+
'mphantom',
|
|
48
|
+
'mprescripts',
|
|
49
|
+
'mroot',
|
|
50
|
+
'mrow',
|
|
51
|
+
'ms',
|
|
52
|
+
'mspace',
|
|
53
|
+
'msqrt',
|
|
54
|
+
'mstyle',
|
|
55
|
+
'msub',
|
|
56
|
+
'msubsup',
|
|
57
|
+
'msup',
|
|
58
|
+
'mtable',
|
|
59
|
+
'mtd',
|
|
60
|
+
'mtext',
|
|
61
|
+
'mtr',
|
|
62
|
+
'munder',
|
|
63
|
+
'munderover',
|
|
64
|
+
'semantics',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
|
|
68
|
+
...sanitizeHtml.defaults.allowedAttributes,
|
|
69
|
+
'*': ['id', 'class', 'title', 'lang', 'dir', 'role', 'aria-label', 'aria-hidden'],
|
|
70
|
+
a: ['href', 'name', 'target', 'rel', 'id', 'class', 'title'],
|
|
71
|
+
img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading', 'decoding', 'class', 'id'],
|
|
72
|
+
source: ['src', 'srcset', 'type'],
|
|
73
|
+
td: ['colspan', 'rowspan', 'align'],
|
|
74
|
+
th: ['colspan', 'rowspan', 'align', 'scope'],
|
|
75
|
+
ol: ['start', 'reversed', 'type'],
|
|
76
|
+
li: ['value'],
|
|
77
|
+
math: ['display', 'xmlns'],
|
|
78
|
+
annotation: ['encoding'],
|
|
79
|
+
'annotation-xml': ['encoding'],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function sanitizeRenderedHtml(html: string): string {
|
|
83
|
+
return sanitizeHtml(html, {
|
|
84
|
+
allowedTags,
|
|
85
|
+
allowedAttributes,
|
|
86
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
87
|
+
allowedSchemesByTag: {
|
|
88
|
+
img: ['http', 'https'],
|
|
89
|
+
},
|
|
90
|
+
allowProtocolRelative: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default function RstRenderer({ content, html, latex = false, slug, slugRegistry }: RstRendererProps) {
|
|
95
|
+
if (html) {
|
|
96
|
+
const sanitizedHtml = sanitizeRenderedHtml(html).replace(
|
|
97
|
+
/<table\b([^>]*)>/g,
|
|
98
|
+
'<div class="rst-table-wrapper"><table$1>'
|
|
99
|
+
).replace(/<\/table>/g, '</table></div>');
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
{latex && <KatexStyles />}
|
|
104
|
+
<div className="bg-background">
|
|
105
|
+
<div
|
|
106
|
+
className={`${proseClasses} rst-rendered`}
|
|
107
|
+
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<MarkdownRenderer
|
|
116
|
+
content={rstToMarkdown(content)}
|
|
117
|
+
latex={latex}
|
|
118
|
+
slug={slug}
|
|
119
|
+
slugRegistry={slugRegistry}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|
|
2
2
|
import { Suspense } from 'react';
|
|
3
3
|
import { getAuthorSlug, PostData, BacklinkSource, SlugRegistryEntry, CollectionContext } from '@/lib/markdown';
|
|
4
4
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
5
|
+
import RstRenderer from '@/components/RstRenderer';
|
|
5
6
|
import RelatedPosts from '@/components/RelatedPosts';
|
|
6
7
|
import SeriesList from '@/components/SeriesList';
|
|
7
8
|
import PostSidebar from '@/components/PostSidebar';
|
|
@@ -41,6 +42,9 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
41
42
|
? `${siteConfig.baseUrl.replace(/\/+$/, '')}${getStaticPageUrl(post.slug)}`
|
|
42
43
|
: `${siteConfig.baseUrl}${getPostUrl(post)}`;
|
|
43
44
|
const commentSlug = isStaticPage ? `pages/${post.slug}` : post.slug;
|
|
45
|
+
const bodyRenderer = post.sourceFormat === 'rst'
|
|
46
|
+
? <RstRenderer content={post.content} html={post.renderedHtml} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />
|
|
47
|
+
: <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />;
|
|
44
48
|
|
|
45
49
|
return (
|
|
46
50
|
<div className="layout-container">
|
|
@@ -136,7 +140,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
136
140
|
</div>
|
|
137
141
|
)}
|
|
138
142
|
|
|
139
|
-
|
|
143
|
+
{bodyRenderer}
|
|
140
144
|
|
|
141
145
|
{siteConfig.posts?.authors?.showAuthorCard !== false && (
|
|
142
146
|
<AuthorCard authors={post.authors} />
|