@hutusi/amytis 1.13.0 → 1.14.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 (38) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/GEMINI.md +9 -1
  3. package/README.md +3 -1
  4. package/README.zh.md +3 -1
  5. package/bun.lock +78 -74
  6. package/content/flows/2026/03/05.md +1 -0
  7. package/content/flows/2026/03/07.md +2 -0
  8. package/content/series/modern-web-dev/index.mdx +4 -2
  9. package/docs/ARCHITECTURE.md +8 -1
  10. package/docs/DIGITAL_GARDEN.md +22 -1
  11. package/package.json +12 -12
  12. package/scripts/new-flow.ts +1 -0
  13. package/src/app/all.atom/route.ts +7 -0
  14. package/src/app/all.xml/route.ts +7 -0
  15. package/src/app/archive/page.tsx +7 -4
  16. package/src/app/feed.atom/route.ts +2 -57
  17. package/src/app/feed.xml/route.ts +2 -64
  18. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  19. package/src/app/flows/feed.atom/route.ts +7 -0
  20. package/src/app/flows/feed.xml/route.ts +7 -0
  21. package/src/app/page.tsx +1 -0
  22. package/src/app/posts/feed.atom/route.ts +9 -0
  23. package/src/app/posts/feed.xml/route.ts +9 -0
  24. package/src/components/FlowCalendarSidebar.tsx +1 -1
  25. package/src/components/FlowContent.tsx +2 -1
  26. package/src/components/FlowTimelineEntry.tsx +7 -1
  27. package/src/components/Footer.tsx +1 -1
  28. package/src/components/MarkdownRenderer.test.tsx +6 -0
  29. package/src/components/MarkdownRenderer.tsx +18 -16
  30. package/src/components/Navbar.tsx +1 -1
  31. package/src/components/PostSidebar.tsx +1 -1
  32. package/src/components/RecentNotesSection.tsx +4 -0
  33. package/src/lib/feed-utils.ts +158 -18
  34. package/src/lib/markdown.ts +16 -4
  35. package/tests/e2e/navigation.test.ts +26 -0
  36. package/tests/integration/collections.test.ts +17 -2
  37. package/tests/integration/feed-utils.test.ts +52 -0
  38. package/tests/integration/flow-title.test.ts +53 -0
@@ -1,4 +1,5 @@
1
1
  ---
2
+ title: 'JSDoc type comments'
2
3
  tags: ["typescript", "tooling"]
3
4
  ---
4
5
 
@@ -2,6 +2,8 @@
2
2
  tags: ["ai", "workflow"]
3
3
  ---
4
4
 
5
+ # Using Claude Code
6
+
5
7
  Been using Claude Code for a week now as a daily coding assistant. A few observations so far.
6
8
 
7
9
  It is most useful for tasks where the shape of the solution is clear but the execution is tedious — refactoring, writing tests, tracking down why a CSS rule isn't applying. For genuinely novel design problems, talking through the approach matters more than generating code.
@@ -5,9 +5,11 @@ excerpt: "A curated path through modern web development: JavaScript fundamentals
5
5
  date: "2026-03-01"
6
6
  featured: true
7
7
  items:
8
- - post: asynchronous-javascript
9
- - post: understanding-react-hooks
8
+ - post: posts/asynchronous-javascript
9
+ - post: posts/understanding-react-hooks
10
10
  - series: nextjs-deep-dive
11
+ - post: markdown-showcase/中文测试文章
12
+ - post: digital-garden/02-architecture
11
13
  ---
12
14
 
13
15
  This collection assembles the essential reading for anyone building modern web applications. Start with the JavaScript fundamentals that underpin everything, move into React patterns, then go deep on Next.js.
@@ -57,7 +57,14 @@ src/app/
57
57
  authors/[author]/page.tsx
58
58
  archive/page.tsx
59
59
  graph/page.tsx
60
- feed.xml/route.ts
60
+ feed.xml/route.ts # Main curated RSS feed
61
+ feed.atom/route.ts # Main curated Atom feed
62
+ all.xml/route.ts # Firehose RSS feed
63
+ all.atom/route.ts # Firehose Atom feed
64
+ posts/feed.xml/route.ts # Posts-only RSS feed
65
+ posts/feed.atom/route.ts # Posts-only Atom feed
66
+ flows/feed.xml/route.ts # Flows-only RSS feed
67
+ flows/feed.atom/route.ts # Flows-only Atom feed
61
68
  search.json/route.ts
62
69
  sitemap.ts
63
70
  [slug]/page.tsx # Static pages + custom series listing path
@@ -37,12 +37,33 @@ The `/graph` route visualizes your entire digital garden as an interactive netwo
37
37
  - **Edges**: Represent wiki-links connecting them.
38
38
  - **Interaction**: Click a node to navigate to that page.
39
39
 
40
- ### 5. Flows (`/flows`)
40
+ ### 5. Collections (`/series`)
41
+ While a "Series" is typically a linear progression of posts (e.g., Part 1, Part 2), a **Collection** allows you to manually curate an arbitrary list of posts and other series into a single grouped page.
42
+
43
+ To create a collection, set `type: collection` in a series index file:
44
+
45
+ - **Location:** `content/series/[collection-slug]/index.mdx`
46
+ - **Frontmatter:**
47
+ ```yaml
48
+ ---
49
+ title: "Modern Web Development"
50
+ type: collection
51
+ items:
52
+ - post: posts/asynchronous-javascript # Namespaced reference to a standalone post
53
+ - post: ai-nexus-weekly/week-11 # Namespaced reference to a post inside another series
54
+ - post: understanding-react-hooks # Fallback reference (searches all posts by slug)
55
+ - series: nextjs-deep-dive # Embeds all posts from another series
56
+ ---
57
+ ```
58
+ Using a namespace (`folder/slug`) in the `post` definition prevents slug collisions and explicitly targets the exact file location.
59
+
60
+ ### 6. Flows (`/flows`)
41
61
  Flows are a stream-style collection of daily notes, micro-blogging, or imported chat logs. They are ideal for quick thoughts that don't necessarily warrant a full blog post.
42
62
 
43
63
  - **Location:** `content/flows/YYYY/MM/DD.mdx`
44
64
  - **Navigation:** Grouped by date in a timeline view.
45
65
  - **Importing:** Use `bun run new-flow-from-chat` to bring in external conversations.
66
+ - **Optional Title:** Set `title` in frontmatter or use an `# Heading` in the content to display a title alongside the date.
46
67
 
47
68
  ## How to Use
48
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hutusi/amytis",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "A high-performance digital garden and blog engine with Next.js 16 and Tailwind CSS v4",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,16 +48,16 @@
48
48
  "github-slugger": "^2.0.0",
49
49
  "gray-matter": "^4.0.3",
50
50
  "image-size": "^2.0.2",
51
- "katex": "^0.16.33",
52
- "mermaid": "^11.12.3",
53
- "next": "16.1.6",
51
+ "katex": "^0.16.42",
52
+ "mermaid": "^11.13.0",
53
+ "next": "16.2.1",
54
54
  "next-image-export-optimizer": "^1.20.1",
55
55
  "next-themes": "^0.4.6",
56
56
  "react": "19.2.4",
57
57
  "react-dom": "19.2.4",
58
- "react-icons": "^5.5.0",
58
+ "react-icons": "^5.6.0",
59
59
  "react-markdown": "^10.1.0",
60
- "react-syntax-highlighter": "^16.1.0",
60
+ "react-syntax-highlighter": "^16.1.1",
61
61
  "rehype-katex": "^7.0.1",
62
62
  "rehype-raw": "^7.0.0",
63
63
  "rehype-slug": "^6.0.0",
@@ -72,22 +72,22 @@
72
72
  },
73
73
  "devDependencies": {
74
74
  "@playwright/test": "^1.58.2",
75
- "@tailwindcss/postcss": "^4.1.18",
76
- "@types/bun": "^1.3.9",
75
+ "@tailwindcss/postcss": "^4.2.2",
76
+ "@types/bun": "^1.3.11",
77
77
  "@types/d3": "^7.4.3",
78
78
  "@types/hast": "^3.0.4",
79
79
  "@types/image-size": "^0.8.0",
80
80
  "@types/mdast": "^4.0.4",
81
- "@types/node": "^24.10.13",
81
+ "@types/node": "^24.12.0",
82
82
  "@types/react": "^19.2.14",
83
83
  "@types/react-dom": "^19.2.3",
84
84
  "@types/react-syntax-highlighter": "^15.5.13",
85
85
  "babel-plugin-react-compiler": "1.0.0",
86
- "eslint": "^9.0.0",
87
- "eslint-config-next": "16.1.6",
86
+ "eslint": "^9.39.4",
87
+ "eslint-config-next": "16.2.1",
88
88
  "pagefind": "^1.4.0",
89
89
  "pdf-to-img": "^5.0.0",
90
- "tailwindcss": "^4.1.18",
90
+ "tailwindcss": "^4.2.2",
91
91
  "typescript": "^5.9.3"
92
92
  },
93
93
  "ignoreScripts": [
@@ -33,6 +33,7 @@ if (!fs.existsSync(dirPath)) {
33
33
  }
34
34
 
35
35
  const content = `---
36
+ # title: ""
36
37
  tags: []
37
38
  ---
38
39
 
@@ -0,0 +1,7 @@
1
+ import { generateAtomFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateAtomFeed('all', '/all.atom');
7
+ }
@@ -0,0 +1,7 @@
1
+ import { generateRssFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateRssFeed('all', '/all.xml');
7
+ }
@@ -128,12 +128,12 @@ export default function ArchivePage() {
128
128
  <li key={post.slug} className="group">
129
129
  <Link href={getPostUrl(post)} className="block no-underline">
130
130
  <div className="flex flex-col sm:flex-row sm:items-baseline justify-between gap-2 sm:gap-6">
131
- <div className="flex items-baseline gap-6">
131
+ <div className="flex items-baseline gap-6 min-w-0 flex-1">
132
132
  <span className="font-mono text-base text-muted shrink-0 w-8">
133
133
  {day}
134
134
  </span>
135
- <div className="flex items-baseline gap-2">
136
- <h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200">
135
+ <div className="flex items-baseline gap-2 min-w-0 flex-1">
136
+ <h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200 truncate">
137
137
  {post.title}
138
138
  </h4>
139
139
  {post.series && (
@@ -147,7 +147,10 @@ export default function ArchivePage() {
147
147
  </div>
148
148
  </div>
149
149
  {showAuthors && post.authors.length > 0 && (
150
- <span className="text-sm font-sans italic text-muted/60 shrink-0 hidden sm:block">
150
+ <span
151
+ title={post.authors.join(', ')}
152
+ className="text-sm font-sans italic text-muted/60 hidden sm:block max-w-[12rem] md:max-w-[16rem] lg:max-w-[20rem] truncate text-right shrink-0"
153
+ >
151
154
  {post.authors.join(', ')}
152
155
  </span>
153
156
  )}
@@ -1,62 +1,7 @@
1
- import { siteConfig } from '../../../site.config';
2
- import { resolveLocale } from '@/lib/i18n';
3
- import { getFeedItems } from '@/lib/feed-utils';
1
+ import { generateAtomFeed } from '@/lib/feed-utils';
4
2
 
5
3
  export const dynamic = 'force-static';
6
4
 
7
- const escapeXml = (v: string) =>
8
- v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
9
- .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
10
-
11
- const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
12
-
13
5
  export async function GET() {
14
- const { format, content: contentMode } = siteConfig.feed;
15
- if (format === 'rss') {
16
- return new Response('Not Found', { status: 404 });
17
- }
18
-
19
- const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
20
- const items = getFeedItems();
21
- const useFullContent = contentMode === 'full';
22
- const feedUpdated = items[0]?.date.toISOString() ?? new Date().toISOString();
23
-
24
- const entriesXml = items
25
- .map((item) => {
26
- const contentXml = useFullContent
27
- ? `<content type="html"><![CDATA[${escapeCdata(item.content)}]]></content>\n <summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`
28
- : `<summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`;
29
- const authorsXml = item.authors?.map((a) => `<author><name>${escapeXml(a)}</name></author>`).join('') ?? '';
30
- const categoriesXml = item.tags.map((tag) => `<category term="${escapeXml(tag)}" />`).join('');
31
- return `
32
- <entry>
33
- <title><![CDATA[${escapeCdata(item.title)}]]></title>
34
- <link href="${escapeXml(item.url)}" />
35
- <id>${escapeXml(item.url)}</id>
36
- <published>${item.date.toISOString()}</published>
37
- <updated>${item.date.toISOString()}</updated>
38
- ${contentXml}
39
- ${authorsXml}
40
- ${categoriesXml}
41
- </entry>`;
42
- })
43
- .join('');
44
-
45
- const atomXml = `<?xml version="1.0" encoding="UTF-8" ?>
46
- <feed xmlns="http://www.w3.org/2005/Atom">
47
- <title><![CDATA[${escapeCdata(resolveLocale(siteConfig.title))}]]></title>
48
- <link href="${escapeXml(baseUrl)}" />
49
- <link href="${escapeXml(baseUrl)}/feed.atom" rel="self" type="application/atom+xml" />
50
- <id>${escapeXml(baseUrl)}/feed.atom</id>
51
- <updated>${feedUpdated}</updated>
52
- <subtitle><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></subtitle>
53
- ${entriesXml}
54
- </feed>`;
55
-
56
- return new Response(atomXml, {
57
- headers: {
58
- 'Content-Type': 'application/atom+xml; charset=utf-8',
59
- 'Cache-Control': 'public, max-age=3600',
60
- },
61
- });
6
+ return generateAtomFeed('main', '/feed.atom');
62
7
  }
@@ -1,69 +1,7 @@
1
- import { siteConfig } from '../../../site.config';
2
- import { resolveLocale } from '@/lib/i18n';
3
- import { getFeedItems } from '@/lib/feed-utils';
1
+ import { generateRssFeed } from '@/lib/feed-utils';
4
2
 
5
3
  export const dynamic = 'force-static';
6
4
 
7
- const escapeXml = (v: string) =>
8
- v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
9
- .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
10
-
11
- const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
12
-
13
5
  export async function GET() {
14
- const { format, content: contentMode } = siteConfig.feed;
15
- if (format === 'atom') {
16
- return new Response('Not Found', { status: 404 });
17
- }
18
-
19
- const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
20
- const items = getFeedItems();
21
- const useFullContent = contentMode === 'full';
22
- const contentNs = useFullContent ? ' xmlns:content="http://purl.org/rss/modules/content/"' : '';
23
- const siteTitle = resolveLocale(siteConfig.title);
24
- const lastBuildDate = items[0]?.date.toUTCString() ?? new Date().toUTCString();
25
-
26
- const imageXml = siteConfig.ogImage
27
- ? `\n <image>\n <url>${escapeXml(baseUrl + siteConfig.ogImage)}</url>\n <title>${escapeXml(siteTitle)}</title>\n <link>${escapeXml(baseUrl)}</link>\n </image>`
28
- : '';
29
-
30
- const rssItemsXml = items
31
- .map((item) => {
32
- const fullContentXml = useFullContent
33
- ? `\n <content:encoded><![CDATA[${escapeCdata(item.content)}]]></content:encoded>`
34
- : '';
35
- const authorsXml = item.authors?.length
36
- ? item.authors.map((a) => `\n <dc:creator><![CDATA[${escapeCdata(a)}]]></dc:creator>`).join('')
37
- : '';
38
- return `
39
- <item>
40
- <title><![CDATA[${escapeCdata(item.title)}]]></title>
41
- <link>${escapeXml(item.url)}</link>
42
- <guid isPermaLink="true">${escapeXml(item.url)}</guid>
43
- <pubDate>${item.date.toUTCString()}</pubDate>
44
- <description><![CDATA[${escapeCdata(item.excerpt)}]]></description>${fullContentXml}${authorsXml}
45
- ${item.tags.map((tag) => `<category><![CDATA[${escapeCdata(tag)}]]></category>`).join('')}
46
- </item>`;
47
- })
48
- .join('');
49
-
50
- const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
51
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"${contentNs}>
52
- <channel>
53
- <title><![CDATA[${escapeCdata(siteTitle)}]]></title>
54
- <link>${escapeXml(baseUrl)}</link>
55
- <description><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></description>
56
- <language>${siteConfig.i18n.defaultLocale}</language>
57
- <lastBuildDate>${lastBuildDate}</lastBuildDate>
58
- <atom:link href="${escapeXml(baseUrl)}/feed.xml" rel="self" type="application/rss+xml" />${imageXml}
59
- ${rssItemsXml}
60
- </channel>
61
- </rss>`;
62
-
63
- return new Response(rssXml, {
64
- headers: {
65
- 'Content-Type': 'application/rss+xml; charset=utf-8',
66
- 'Cache-Control': 'public, max-age=3600',
67
- },
68
- });
6
+ return generateRssFeed('main', '/feed.xml');
69
7
  }
@@ -88,6 +88,9 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
88
88
  {/* Header */}
89
89
  <header className="mb-8">
90
90
  <time className="text-base font-mono text-accent" data-pagefind-meta="date[content]">{flow.date}</time>
91
+ {flow.title !== flow.date && (
92
+ <h1 className="mt-2 text-xl sm:text-2xl font-serif font-bold text-heading">{flow.title}</h1>
93
+ )}
91
94
  {flow.tags.length > 0 && (
92
95
  <div className="mt-3 flex flex-wrap gap-2">
93
96
  {flow.tags.map(tag => (
@@ -121,6 +124,11 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
121
124
  <div className="text-sm font-mono text-heading group-hover:text-accent transition-colors">
122
125
  {prev.date}
123
126
  </div>
127
+ {prev.title !== prev.date && (
128
+ <div className="text-sm text-muted group-hover:text-accent/80 transition-colors truncate">
129
+ {prev.title}
130
+ </div>
131
+ )}
124
132
  </Link>
125
133
  ) : <div />}
126
134
  {next ? (
@@ -132,6 +140,11 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
132
140
  <div className="text-sm font-mono text-heading group-hover:text-accent transition-colors">
133
141
  {next.date}
134
142
  </div>
143
+ {next.title !== next.date && (
144
+ <div className="text-sm text-muted group-hover:text-accent/80 transition-colors truncate">
145
+ {next.title}
146
+ </div>
147
+ )}
135
148
  </Link>
136
149
  ) : <div />}
137
150
  </nav>
@@ -0,0 +1,7 @@
1
+ import { generateAtomFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateAtomFeed('flows', '/flows/feed.atom');
7
+ }
@@ -0,0 +1,7 @@
1
+ import { generateRssFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateRssFeed('flows', '/flows/feed.xml');
7
+ }
package/src/app/page.tsx CHANGED
@@ -96,6 +96,7 @@ export default function Home() {
96
96
  ? recentFlows.map(f => ({
97
97
  slug: f.slug,
98
98
  date: f.date,
99
+ title: f.title,
99
100
  excerpt: f.excerpt,
100
101
  }))
101
102
  : [];
@@ -0,0 +1,9 @@
1
+ import { generateAtomFeed } from '@/lib/feed-utils';
2
+ import { getPostsBasePath } from '@/lib/urls';
3
+
4
+ export const dynamic = 'force-static';
5
+
6
+ export async function GET() {
7
+ const basePath = getPostsBasePath();
8
+ return generateAtomFeed('posts', `/${basePath}/feed.atom`);
9
+ }
@@ -0,0 +1,9 @@
1
+ import { generateRssFeed } from '@/lib/feed-utils';
2
+ import { getPostsBasePath } from '@/lib/urls';
3
+
4
+ export const dynamic = 'force-static';
5
+
6
+ export async function GET() {
7
+ const basePath = getPostsBasePath();
8
+ return generateRssFeed('posts', `/${basePath}/feed.xml`);
9
+ }
@@ -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 */}
@@ -41,4 +41,10 @@ describe("MarkdownRenderer", () => {
41
41
  expect(html).toContain("not-prose w-full min-w-0 max-w-full");
42
42
  expect(html).toContain("overflow-x-auto");
43
43
  });
44
+
45
+ test("wraps content in a background container for copy-paste fidelity", () => {
46
+ const content = "Hello world";
47
+ const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
48
+ expect(html).toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
49
+ });
44
50
  });
@@ -164,22 +164,24 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
164
164
  return (
165
165
  <>
166
166
  {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>
167
+ <div className="bg-background"> {/* Explicit background for better copy-paste fidelity */}
168
+ <div className="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
169
+ prose-headings:font-serif prose-headings:text-heading
170
+ prose-p:text-foreground prose-p:leading-loose
171
+ prose-strong:text-heading prose-strong:font-semibold
172
+ 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
173
+ prose-code:before:content-none prose-code:after:content-none
174
+ prose-blockquote:italic
175
+ prose-th:text-heading prose-td:text-foreground
176
+ dark:prose-invert">
177
+ <ReactMarkdown
178
+ remarkPlugins={remarkPlugins}
179
+ rehypePlugins={rehypePlugins}
180
+ components={allComponents}
181
+ >
182
+ {content}
183
+ </ReactMarkdown>
184
+ </div>
183
185
  </div>
184
186
  </>
185
187
  );
@@ -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>