@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.
- package/CHANGELOG.md +16 -0
- package/GEMINI.md +9 -1
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/bun.lock +78 -74
- 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/docs/ARCHITECTURE.md +8 -1
- package/docs/DIGITAL_GARDEN.md +22 -1
- package/package.json +12 -12
- package/scripts/new-flow.ts +1 -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/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/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 +6 -0
- package/src/components/MarkdownRenderer.tsx +18 -16
- package/src/components/Navbar.tsx +1 -1
- package/src/components/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/markdown.ts +16 -4
- package/tests/e2e/navigation.test.ts +26 -0
- package/tests/integration/collections.test.ts +17 -2
- package/tests/integration/feed-utils.test.ts +52 -0
- package/tests/integration/flow-title.test.ts +53 -0
|
@@ -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.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
package/docs/DIGITAL_GARDEN.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
52
|
-
"mermaid": "^11.
|
|
53
|
-
"next": "16.1
|
|
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.
|
|
58
|
+
"react-icons": "^5.6.0",
|
|
59
59
|
"react-markdown": "^10.1.0",
|
|
60
|
-
"react-syntax-highlighter": "^16.1.
|
|
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.
|
|
76
|
-
"@types/bun": "^1.3.
|
|
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.
|
|
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.
|
|
87
|
-
"eslint-config-next": "16.1
|
|
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.
|
|
90
|
+
"tailwindcss": "^4.2.2",
|
|
91
91
|
"typescript": "^5.9.3"
|
|
92
92
|
},
|
|
93
93
|
"ignoreScripts": [
|
package/scripts/new-flow.ts
CHANGED
package/src/app/archive/page.tsx
CHANGED
|
@@ -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
|
|
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 {
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
10
|
-
|
|
11
|
-
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
12
|
-
|
|
13
5
|
export async function GET() {
|
|
14
|
-
|
|
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 {
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
10
|
-
|
|
11
|
-
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
12
|
-
|
|
13
5
|
export async function GET() {
|
|
14
|
-
|
|
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>
|
package/src/app/page.tsx
CHANGED
|
@@ -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
|
|
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="
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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>
|