@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.
Files changed (91) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +32 -0
  4. package/GEMINI.md +9 -1
  5. package/README.md +36 -2
  6. package/README.zh.md +36 -2
  7. package/TODO.md +10 -0
  8. package/bun.lock +123 -91
  9. package/content/flows/2026/03/05.md +1 -0
  10. package/content/flows/2026/03/07.md +2 -0
  11. package/content/series/modern-web-dev/index.mdx +4 -2
  12. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  13. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  14. package/content/series/rst-legacy/getting-started.rst +24 -0
  15. package/content/series/rst-legacy/index.rst +9 -0
  16. package/content/series/rst-readme/README.rst +9 -0
  17. package/content/series/rst-readme/readme-index-post.rst +10 -0
  18. package/content/series/rst-toctree/first-post.rst +6 -0
  19. package/content/series/rst-toctree/index.rst +10 -0
  20. package/content/series/rst-toctree/second-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  22. package/content/series/rst-toctree-precedence/index.rst +12 -0
  23. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  24. package/docs/ARCHITECTURE.md +30 -4
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/eslint.config.mjs +2 -0
  28. package/next.config.ts +2 -2
  29. package/package.json +27 -21
  30. package/packages/create-amytis/package.json +1 -1
  31. package/packages/create-amytis/src/index.test.ts +43 -1
  32. package/packages/create-amytis/src/index.ts +64 -8
  33. package/public/next-image-export-optimizer-hashes.json +14 -73
  34. package/scripts/build-pagefind.ts +172 -0
  35. package/scripts/copy-assets.ts +246 -56
  36. package/scripts/generate-knowledge-graph.ts +2 -1
  37. package/scripts/new-flow.ts +1 -0
  38. package/scripts/render-rst.py +719 -0
  39. package/scripts/run-with-rst-python.ts +42 -0
  40. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  41. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  42. package/src/app/all.atom/route.ts +7 -0
  43. package/src/app/all.xml/route.ts +7 -0
  44. package/src/app/archive/page.tsx +7 -4
  45. package/src/app/feed.atom/route.ts +2 -57
  46. package/src/app/feed.xml/route.ts +2 -64
  47. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  48. package/src/app/flows/feed.atom/route.ts +7 -0
  49. package/src/app/flows/feed.xml/route.ts +7 -0
  50. package/src/app/globals.css +165 -0
  51. package/src/app/page.tsx +1 -0
  52. package/src/app/posts/feed.atom/route.ts +9 -0
  53. package/src/app/posts/feed.xml/route.ts +9 -0
  54. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  55. package/src/app/series/[slug]/page.tsx +11 -13
  56. package/src/app/series/page.tsx +3 -3
  57. package/src/components/AuthorCard.tsx +25 -16
  58. package/src/components/CoverImage.tsx +5 -2
  59. package/src/components/FlowCalendarSidebar.tsx +1 -1
  60. package/src/components/FlowContent.tsx +2 -1
  61. package/src/components/FlowTimelineEntry.tsx +7 -1
  62. package/src/components/Footer.tsx +1 -1
  63. package/src/components/MarkdownRenderer.test.tsx +22 -0
  64. package/src/components/MarkdownRenderer.tsx +22 -17
  65. package/src/components/Navbar.tsx +1 -1
  66. package/src/components/PostSidebar.tsx +1 -1
  67. package/src/components/RecentNotesSection.tsx +4 -0
  68. package/src/components/RstRenderer.test.tsx +93 -0
  69. package/src/components/RstRenderer.tsx +122 -0
  70. package/src/layouts/PostLayout.tsx +5 -1
  71. package/src/layouts/SimpleLayout.tsx +10 -3
  72. package/src/lib/feed-utils.ts +158 -18
  73. package/src/lib/image-utils.test.ts +19 -0
  74. package/src/lib/image-utils.ts +11 -0
  75. package/src/lib/markdown.test.ts +140 -2
  76. package/src/lib/markdown.ts +747 -214
  77. package/src/lib/rehype-image-metadata.ts +2 -2
  78. package/src/lib/rst-renderer.test.ts +355 -0
  79. package/src/lib/rst-renderer.ts +617 -0
  80. package/src/lib/rst.test.ts +140 -0
  81. package/src/lib/rst.ts +470 -0
  82. package/src/lib/series-redirects.ts +42 -0
  83. package/tests/e2e/navigation.test.ts +26 -0
  84. package/tests/integration/collections.test.ts +17 -2
  85. package/tests/integration/feed-utils.test.ts +65 -0
  86. package/tests/integration/flow-title.test.ts +53 -0
  87. package/tests/integration/reading-time-headings.test.ts +5 -9
  88. package/tests/integration/series-draft.test.ts +16 -2
  89. package/tests/integration/series.test.ts +93 -0
  90. package/tests/tooling/build-pagefind.test.ts +66 -0
  91. 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 = decodeURIComponent(rawSlug);
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 = decodeURIComponent(rawSlug);
99
+ const slug = safeDecodeParam(rawSlug);
102
100
  const currentPath = `/series/${slug}`;
103
101
 
104
102
  const redirect = findSeriesByRedirectFrom(currentPath);
@@ -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 = allSeries[a][0]?.date || '';
24
- const latestB = allSeries[b][0]?.date || '';
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={isDev || isExternal(profile.avatar)}
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
- <figure key={index} className="flex flex-col items-center gap-1.5">
65
- <ExportedImage
66
- src={item.image}
67
- alt={item.description}
68
- width={72}
69
- height={72}
70
- className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
71
- unoptimized={isDev || isExternal(item.image)}
72
- />
73
- <figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
74
- {item.description}
75
- </figcaption>
76
- </figure>
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={isDev || !!isCdn}
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: string;
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 = "![alt text](/images/background-new-wave.avif)";
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 = "![alt text](/images/already-optimized.webp)";
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="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
168
- prose-headings:font-serif prose-headings:text-heading
169
- prose-p:text-foreground prose-p:leading-loose
170
- prose-strong:text-heading prose-strong:font-semibold
171
- 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
172
- prose-code:before:content-none prose-code:after:content-none
173
- prose-blockquote:italic
174
- prose-th:text-heading prose-td:text-foreground
175
- dark:prose-invert">
176
- <ReactMarkdown
177
- remarkPlugins={remarkPlugins}
178
- rehypePlugins={rehypePlugins}
179
- components={allComponents}
180
- >
181
- {content}
182
- </ReactMarkdown>
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
- <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />
143
+ {bodyRenderer}
140
144
 
141
145
  {siteConfig.posts?.authors?.showAuthorCard !== false && (
142
146
  <AuthorCard authors={post.authors} />