hd-garden 0.4.0 → 0.5.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 (56) hide show
  1. package/.agents/skills/garden-interactive-chart/SKILL.md +10 -10
  2. package/.agents/skills/garden-ui/SKILL.md +4 -4
  3. package/Makefile +2 -3
  4. package/app/a2/[slug]/page.tsx +0 -1
  5. package/app/a2/page.tsx +1 -1
  6. package/app/about/page.tsx +8 -12
  7. package/app/admin/page.tsx +2 -2
  8. package/app/blog/[...path]/page.tsx +2 -13
  9. package/app/blog/page.tsx +2 -2
  10. package/app/columns/[column]/[...path]/page.tsx +2 -4
  11. package/app/columns/page.tsx +2 -2
  12. package/app/components/AdminDashboard.tsx +3 -3
  13. package/app/components/ArticleMeasure.tsx +114 -42
  14. package/app/components/ArticleTags.tsx +17 -0
  15. package/app/components/BlogArchive.tsx +85 -33
  16. package/app/components/ContentEmptyState.tsx +6 -6
  17. package/app/components/GardenAdminGuide.tsx +1 -1
  18. package/app/components/GardenContentGuide.tsx +1 -2
  19. package/app/components/GardenMediaGuide.tsx +3 -3
  20. package/app/components/GithubProfile.tsx +1 -1
  21. package/app/components/MarkdownImage.tsx +4 -2
  22. package/app/components/SiteFooter.tsx +5 -5
  23. package/app/components/SiteHeader.tsx +1 -5
  24. package/app/components/TagRedirect.tsx +17 -0
  25. package/app/garden/page.tsx +6 -6
  26. package/app/garden-modules.d.ts +0 -1
  27. package/app/globals.css +19 -101
  28. package/app/lib/a2-meta.ts +10 -0
  29. package/app/lib/a2-preview.ts +0 -2
  30. package/app/lib/content.ts +14 -8
  31. package/app/lib/fallback-site-config.ts +15 -15
  32. package/app/lib/generated-content.ts +58 -47
  33. package/app/lib/tag-filter.ts +114 -0
  34. package/app/not-found.tsx +13 -0
  35. package/app/page.tsx +10 -27
  36. package/app/styles/a2.css +96 -15
  37. package/app/styles/shell.css +56 -61
  38. package/app/styles/tokens.css +35 -22
  39. package/app/tags/page.tsx +20 -9
  40. package/package.json +1 -1
  41. package/scripts/build-content.mjs +3 -3
  42. package/scripts/check-post.mjs +0 -3
  43. package/scripts/new-post.mjs +1 -3
  44. package/site.config.yaml +15 -15
  45. package/templates/site/Makefile +2 -3
  46. package/templates/site/site.config.yaml +15 -15
  47. package/ui/garden/check.mjs +2 -4
  48. package/ui/garden/enhance.mjs +57 -0
  49. package/ui/garden/title.mjs +5 -8
  50. package/ui/host.mjs +13 -13
  51. package/ui/preview/dist/garden-ui.css +11 -11
  52. package/ui/preview/dist/garden-ui.esm.js +6 -6
  53. package/ui/preview/dist/garden-ui.iife.js +7 -7
  54. package/ui/react/components/media.tsx +2 -2
  55. package/ui/react/ui.css +11 -11
  56. package/app/components/TagIndex.tsx +0 -97
@@ -1,18 +1,21 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { useSearchParams } from "next/navigation";
5
- import { useState } from "react";
6
- import { columns, postHref, posts, topics } from "../lib/content";
4
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
5
+ import { useMemo, useState } from "react";
6
+ import { columnIndexHref, columnOfPost, columns, postHref, posts } from "../lib/content";
7
+ import {
8
+ TAG_PARAM,
9
+ filterByTags,
10
+ selectedTags,
11
+ tagFilterHref,
12
+ tagOptions,
13
+ tagSummary,
14
+ toggleTag,
15
+ visibleTagOptions,
16
+ } from "../lib/tag-filter";
7
17
  import { ContentEmptyState } from "./ContentEmptyState";
8
18
 
9
- export const archiveHref = (topic?: string) => {
10
- const params = new URLSearchParams();
11
- if (topic) params.set("topic", topic);
12
- const query = params.toString();
13
- return query ? `/blog?${query}` : "/blog";
14
- };
15
-
16
19
  function day(value: string) {
17
20
  const date = new Date(value);
18
21
  if (Number.isNaN(date.getTime())) return value.slice(0, 10);
@@ -24,12 +27,37 @@ function day(value: string) {
24
27
  }).format(date);
25
28
  }
26
29
 
30
+ function ColumnLabel({ path }: { path: string }) {
31
+ const column = columnOfPost({ path });
32
+ return column ? <Link className="cat" href={columnIndexHref(column.slug)}>{column.title}</Link> : null;
33
+ }
34
+
35
+ const NO_TAGS: string[] = [];
36
+
37
+ /** Reads the selected tags from the URL; needs a Suspense boundary. */
27
38
  export function BlogArchive() {
39
+ const router = useRouter();
40
+ const pathname = usePathname() || "/blog";
28
41
  const searchParams = useSearchParams();
42
+ const paramKey = searchParams.getAll(TAG_PARAM).join("\u0000");
43
+ const selected = useMemo(() => selectedTags(paramKey ? paramKey.split("\u0000") : []), [paramKey]);
44
+ return <ArchiveView selected={selected} go={(next) => router.push(tagFilterHref(next, pathname), { scroll: false })} />;
45
+ }
46
+
47
+ /**
48
+ * The full, unfiltered list. Rendered into the static page as the Suspense
49
+ * fallback, so the list is there before scripts run (or without them).
50
+ */
51
+ export function BlogArchiveFallback() {
52
+ return <ArchiveView selected={NO_TAGS} go={(next) => window.location.assign(tagFilterHref(next))} />;
53
+ }
54
+
55
+ function ArchiveView({ selected, go }: { selected: string[]; go: (next: string[]) => void }) {
29
56
  const [compact, setCompact] = useState(false);
30
- const requestedTopic = searchParams.get("topic") ?? "";
31
- const selectedTopic = topics.includes(requestedTopic) ? requestedTopic : undefined;
32
- const filtered = (selectedTopic ? posts.filter((post) => post.topic === selectedTopic) : posts)
57
+ const [expanded, setExpanded] = useState(false);
58
+ const options = useMemo(() => tagOptions(posts, selected), [selected]);
59
+ const { shown, hidden } = visibleTagOptions(options, expanded);
60
+ const filtered = filterByTags(posts, selected)
33
61
  .slice()
34
62
  .sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
35
63
  const years = new Map<string, typeof filtered>();
@@ -53,23 +81,37 @@ export function BlogArchive() {
53
81
  return (
54
82
  <div className="sh-listgrid">
55
83
  <div>
56
- <ul className="sh-filters" aria-label="按分类筛选">
57
- <li>
58
- <Link className="sh-chip" href="/blog" aria-current={selectedTopic ? undefined : "true"}>
59
- 全部<span className="n">{posts.length}</span>
60
- </Link>
61
- </li>
62
- {topics.map((topic) => (
63
- <li key={topic}>
64
- <Link className="sh-chip" href={archiveHref(topic)} aria-current={selectedTopic === topic ? "true" : undefined}>
65
- {topic}<span className="n">{posts.filter((post) => post.topic === topic).length}</span>
66
- </Link>
67
- </li>
68
- ))}
69
- </ul>
84
+ {options.length ? (
85
+ <div className="sh-tagfilter">
86
+ <ul className="sh-tagchips" aria-label="按标签筛选">
87
+ {shown.map((option) => (
88
+ <li key={option.tag}>
89
+ <button
90
+ type="button"
91
+ className="sh-tagchip"
92
+ aria-pressed={option.selected}
93
+ disabled={option.disabled}
94
+ onClick={() => go(toggleTag(selected, option.tag))}
95
+ >
96
+ <span className="label">#{option.tag}</span>
97
+ <span className="n num">{option.next}</span>
98
+ {option.selected ? <span className="x" aria-hidden="true">×</span> : null}
99
+ </button>
100
+ </li>
101
+ ))}
102
+ {hidden > 0 || expanded ? (
103
+ <li>
104
+ <button type="button" className="sh-tagmore" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
105
+ {expanded ? "收起" : `展开全部 ${options.length} 个标签`}
106
+ </button>
107
+ </li>
108
+ ) : null}
109
+ </ul>
110
+ </div>
111
+ ) : null}
70
112
  <div className="sh-status">
71
- <span aria-live="polite">{selectedTopic ? `${selectedTopic} ${filtered.length} 篇` : `全部 ${posts.length} 篇`}</span>
72
- {selectedTopic ? <Link href="/blog">清除筛选</Link> : null}
113
+ <span aria-live="polite">{tagSummary(selected.length, filtered.length)}</span>
114
+ {selected.length ? <button type="button" onClick={() => go([])}>清除</button> : null}
73
115
  <div className="sh-view" role="group" aria-label="列表密度">
74
116
  <button type="button" aria-pressed={!compact} onClick={() => setCompact(false)}>摘要</button>
75
117
  <button type="button" aria-pressed={compact} onClick={() => setCompact(true)}>紧凑</button>
@@ -94,16 +136,26 @@ export function BlogArchive() {
94
136
  </h3>
95
137
  {post.description ? <p>{post.description}</p> : null}
96
138
  </div>
97
- {post.topic ? <Link className="cat" href={archiveHref(post.topic)}>{post.topic}</Link> : null}
139
+ <ColumnLabel path={post.path} />
98
140
  </li>
99
141
  ))}
100
142
  </ol>
101
143
  </section>
102
- )) : <ContentEmptyState kind="posts" />}
144
+ )) : selected.length ? (
145
+ <div className="sh-tagempty">
146
+ <p>没有同时带这些标签的文章。</p>
147
+ <div className="acts">
148
+ {selected.length > 1 ? (
149
+ <button type="button" onClick={() => go(selected.slice(0, -1))}>去掉「{selected[selected.length - 1]}」</button>
150
+ ) : null}
151
+ <button type="button" onClick={() => go([])}>看全部文章</button>
152
+ </div>
153
+ </div>
154
+ ) : <ContentEmptyState kind="posts" />}
103
155
  </div>
104
156
  </div>
105
157
  <aside className="sh-rail" aria-label="按年份跳转">
106
- <h2>按年份</h2>
158
+ {years.size ? <h2>按年份</h2> : null}
107
159
  <ol>
108
160
  {[...years.keys()].map((year) => (
109
161
  <li key={year}>
@@ -117,7 +169,7 @@ export function BlogArchive() {
117
169
  <ol>
118
170
  {series.map(([slug, entries]) => (
119
171
  <li key={slug}>
120
- <Link href={`/columns#${slug}`}><span>{entries[0]?.columnTitle ?? slug}</span><span className="n num">{entries.length}</span></Link>
172
+ <Link href={columnIndexHref(slug)}><span>{entries[0]?.columnTitle ?? slug}</span><span className="n num">{entries.length}</span></Link>
121
173
  </li>
122
174
  ))}
123
175
  </ol>
@@ -6,25 +6,25 @@ import { ArrowRight, Article, Books, Quotes } from "@phosphor-icons/react/ssr";
6
6
  const emptyContent = {
7
7
  posts: {
8
8
  icon: Article,
9
- eyebrow: "NO POSTS YET / 暂无博文",
9
+ eyebrow: "暂无博文",
10
10
  title: "暂无博文。",
11
- description: "发布文章后会在这里按主题与时间归档。",
11
+ description: "文章会按时间排在这里。",
12
12
  actionHref: "/",
13
13
  actionLabel: "返回首页",
14
14
  },
15
15
  thoughts: {
16
16
  icon: Quotes,
17
- eyebrow: "NO THOUGHTS YET / 暂无随想",
17
+ eyebrow: "暂无随想",
18
18
  title: "暂无随想。",
19
- description: "日常的碎片记录、短笔记与链接会落在这里。",
19
+ description: "短笔记和链接会在这里。",
20
20
  actionHref: "/blog",
21
21
  actionLabel: "先读博文",
22
22
  },
23
23
  columns: {
24
24
  icon: Books,
25
- eyebrow: "NO COLUMNS YET / 暂无专栏",
25
+ eyebrow: "暂无专栏",
26
26
  title: "暂无专栏。",
27
- description: "文章积累成系列后会在这里按顺序呈现。",
27
+ description: "一组文章会按顺序列在这里。",
28
28
  actionHref: "/blog",
29
29
  actionLabel: "浏览全部博文",
30
30
  },
@@ -17,7 +17,7 @@ const adminJobs = [
17
17
  },
18
18
  {
19
19
  title: "改文章里的交互",
20
- description: "写作台把一句话收成可交互的块。组件页能看到每一种块长什么样。",
20
+ description: "写作台把一句话收成可以动手试的一块。",
21
21
  },
22
22
  ];
23
23
 
@@ -23,7 +23,6 @@ const postSource = `---
23
23
  title: 我的第一篇文章
24
24
  date: 2026-08-03 09:00:00
25
25
  description: 这篇文章记录我为什么重新开始写博客。
26
- topic: 写作
27
26
  tags: [Markdown, 博客]
28
27
  pinned: false
29
28
  cover: writing/first-post.jpg
@@ -100,7 +99,7 @@ const guides: Guide[] = [
100
99
  <>文件名就是地址。标题和日期写在开头。先存着就标成草稿。</>,
101
100
  <>图片、音频、视频和链接按顺序放进 <code>media</code>。</>,
102
101
  ],
103
- result: "它会出现在随想页和首页,并且有自己的链接。",
102
+ result: "它会出现在随想页,并且有自己的链接。",
104
103
  path: "content/thoughts/walk-after-rain.md",
105
104
  source: thoughtSource,
106
105
  language: "markdown",
@@ -5,7 +5,7 @@ const embedTypes = [
5
5
  },
6
6
  {
7
7
  title: "X / Twitter",
8
- description: "显示成一张带出处的卡片,不依赖对方的脚本。",
8
+ description: "显示成一张带出处的卡片。",
9
9
  },
10
10
  {
11
11
  title: "微信公众号",
@@ -13,7 +13,7 @@ const embedTypes = [
13
13
  },
14
14
  {
15
15
  title: "其他网站",
16
- description: "自己写标题、摘要和封面。不在构建时去抓那个网址。",
16
+ description: "自己写标题、摘要和封面。",
17
17
  },
18
18
  ];
19
19
 
@@ -53,7 +53,7 @@ export function GardenMediaGuide() {
53
53
  </ul>
54
54
 
55
55
  <p className="garden-media-boundary">
56
- 摘要写在文章里。构建时不去抓任意网址。原站打不开,文章还在。需要时用 <code>make embed URL=...</code> 拉一次,看过再贴进去。
56
+ 摘要写在文章里。原站打不开,文章还在。需要时用 <code>make embed URL=...</code> 拉一次,看过再贴进去。
57
57
  </p>
58
58
  </section>
59
59
  );
@@ -253,7 +253,7 @@ export async function GithubProfile() {
253
253
  <header>
254
254
  <div>
255
255
  <p className="eyebrow">PINNED / 置顶仓库</p>
256
- <h3 id="github-pinned-title">正在持续构建的项目</h3>
256
+ <h3 id="github-pinned-title">正在做的项目</h3>
257
257
  </div>
258
258
  <PushPin size={20} aria-hidden="true" />
259
259
  </header>
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
4
4
 
5
- type MarkdownImageProps = ComponentPropsWithoutRef<"img">;
5
+ // react-markdown also hands over its syntax-tree `node`; it must not reach the DOM.
6
+ type MarkdownImageProps = ComponentPropsWithoutRef<"img"> & { node?: unknown };
6
7
 
7
- export function MarkdownImage({ alt = "", src, ...props }: MarkdownImageProps) {
8
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
9
+ export function MarkdownImage({ alt = "", src, node, ...props }: MarkdownImageProps) {
8
10
  const [open, setOpen] = useState(false);
9
11
  const description = alt || "查看原图";
10
12
 
@@ -9,13 +9,13 @@ export function SiteFooter() {
9
9
  © {new Date().getFullYear()} {siteConfig.author.name} · {siteConfig.footer}
10
10
  <span className="footer-framework"> · 基于 <a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a> 构建</span>
11
11
  </p>
12
- <div>
13
- <a href={githubUrl} aria-label="GitHub" rel="me">
14
- <GithubLogo size={19} />
12
+ <div className="sh-foot-links">
13
+ <a href={githubUrl} rel="me">
14
+ <GithubLogo size={16} aria-hidden="true" />
15
15
  GitHub
16
16
  </a>
17
- <a href="/rss.xml" aria-label="RSS 订阅">
18
- <Rss size={18} />
17
+ <a href="/rss.xml">
18
+ <Rss size={16} aria-hidden="true" />
19
19
  RSS
20
20
  </a>
21
21
  </div>
@@ -197,7 +197,6 @@ export function SiteHeader() {
197
197
  { name: "title", weight: 0.5 },
198
198
  { name: "description", weight: 0.3 },
199
199
  { name: "content", weight: 0.15 },
200
- { name: "topic", weight: 0.05 },
201
200
  ],
202
201
  threshold: 0.36,
203
202
  ignoreLocation: true,
@@ -401,10 +400,7 @@ export function SiteHeader() {
401
400
  className={`search-result${activeResult === index ? " active" : ""}`}
402
401
  >
403
402
  <span>
404
- <HighlightedText
405
- text={result.item.topic}
406
- ranges={rangesFor(result.matches, "topic")}
407
- />
403
+ {result.item.section}
408
404
  </span>
409
405
  <strong>
410
406
  <HighlightedText
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ import { useRouter, useSearchParams } from "next/navigation";
4
+ import { useEffect } from "react";
5
+ import { TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
6
+
7
+ /** Old `/tags?t=X` links open the same filter on `/blog?t=X`. */
8
+ export function TagRedirect() {
9
+ const router = useRouter();
10
+ const params = useSearchParams();
11
+ const tags = selectedTags(params.getAll(TAG_PARAM));
12
+ const target = tags.length ? tagFilterHref(tags) : "";
13
+ useEffect(() => {
14
+ if (target) router.replace(target);
15
+ }, [router, target]);
16
+ return null;
17
+ }
@@ -7,7 +7,7 @@ import { siteConfig } from "../site.config";
7
7
 
8
8
  export const metadata = {
9
9
  title: "Garden",
10
- description: "用 Markdown 写文章。推送到 GitHub 之后,页面、搜索和 RSS 一起生成。",
10
+ description: "用 Markdown 写文章。推送之后,页面、搜索和 RSS 一起更新。",
11
11
  };
12
12
 
13
13
  const jumps = [
@@ -45,7 +45,7 @@ const setupSteps = [
45
45
  {
46
46
  title: "发布",
47
47
  command: "make update",
48
- body: "先检查,再提交并推送。main 更新后,网站、搜索和 RSS 会重新生成。",
48
+ body: "先检查,再提交并推送。main 更新后,网站、搜索和 RSS 会一起更新。",
49
49
  },
50
50
  ];
51
51
 
@@ -54,7 +54,7 @@ export default function GardenPage() {
54
54
  <main className="page-shell garden-page">
55
55
  <PageIntro title={siteConfig.pages.garden.title} subtitle={siteConfig.pages.garden.subtitle}>
56
56
  <p className="garden-lede">
57
- 文章放在你自己的仓库里。你改文件,GitHub 构建网站。
57
+ 文章放在你自己的仓库里。你改文件,网站跟着更新。
58
58
  </p>
59
59
  <div className="garden-actions">
60
60
  <a
@@ -133,10 +133,10 @@ export default function GardenPage() {
133
133
  <header className="garden-section-heading">
134
134
  <h2 id="garden-after-title">推送之后</h2>
135
135
  </header>
136
- <p className="garden-after-lead">你的仓库,到 GitHub 构建,再到公开网站。</p>
136
+ <p className="garden-after-lead">推送之后,网站公开更新。</p>
137
137
  <div className="garden-after-copy">
138
- <p>框架画页面。你的文章不放在框架仓库里,而在你自己的内容仓库。读者打开的是生成出来的静态页。</p>
139
- <p>同一次构建会带上正文、目录、前后篇、搜索、RSS 和 sitemap。后台和评论都可以以后再加,不接也能发布。</p>
138
+ <p>文章在你自己的仓库里。读者打开的是已经发布的页面。</p>
139
+ <p>一次更新会带上正文、目录、前后篇、搜索、RSS 和 sitemap。后台和评论都可以以后再加。</p>
140
140
  </div>
141
141
  </section>
142
142
 
@@ -18,7 +18,6 @@ declare module "@garden/generated-content" {
18
18
  updated?: string;
19
19
  publishAt?: string;
20
20
  draft?: boolean;
21
- topic?: string;
22
21
  tags?: string[];
23
22
  references?: { title: string; url?: string }[];
24
23
  pinned?: boolean;
package/app/globals.css CHANGED
@@ -3,8 +3,8 @@
3
3
  @import "./admin-ui.css";
4
4
 
5
5
  :root {
6
- --paper: #f8f8f6;
7
- --paper-deep: #eeeeeb;
6
+ --paper: #faf9f5;
7
+ --paper-deep: #f0eee6;
8
8
  --card: #ffffff;
9
9
  --ink: #202124;
10
10
  --ink-soft: #34383d;
@@ -12,28 +12,28 @@
12
12
  --faint: #a1a6ad;
13
13
  --line: #deded9;
14
14
  --line-strong: #c9c9c2;
15
- --accent: #d82d48;
16
- --accent-soft: #f9e8eb;
17
- --selection: #f3c9d1;
15
+ --accent: #30302e;
16
+ --accent-soft: #f0eee6;
17
+ --selection: #e8e5db;
18
18
  --font-mono: var(--font-geist-mono), "SFMono-Regular", Consolas,
19
19
  "Liberation Mono", "Noto Sans Mono CJK SC", monospace;
20
- --content: 700px;
20
+ --content: 740px;
21
21
  --wide: 1040px;
22
22
  }
23
23
 
24
24
  :root[data-theme="dark"] {
25
- --paper: #171819;
26
- --paper-deep: #202224;
27
- --card: #1e2022;
25
+ --paper: #1f1e1d;
26
+ --paper-deep: #262624;
27
+ --card: #262624;
28
28
  --ink: #ededeb;
29
29
  --ink-soft: #d8d8d4;
30
30
  --muted: #a6adb6;
31
31
  --faint: #747b84;
32
32
  --line: #35383b;
33
33
  --line-strong: #4b4f54;
34
- --accent: #ff5d76;
35
- --accent-soft: #3b242a;
36
- --selection: #68333e;
34
+ --accent: #eeece7;
35
+ --accent-soft: #30302e;
36
+ --selection: rgb(238 236 231 / 18%);
37
37
  }
38
38
 
39
39
  * {
@@ -252,11 +252,6 @@ button {
252
252
  margin-top: 72px;
253
253
  }
254
254
 
255
- .home-thought-list {
256
- display: grid;
257
- gap: 24px;
258
- }
259
-
260
255
  .section-heading {
261
256
  min-height: 40px;
262
257
  margin-bottom: 21px;
@@ -1423,32 +1418,6 @@ button {
1423
1418
  color: var(--accent);
1424
1419
  }
1425
1420
 
1426
- .topic-strip {
1427
- display: flex;
1428
- flex-wrap: wrap;
1429
- gap: 8px;
1430
- margin: 8px 0 38px;
1431
- }
1432
-
1433
- .topic-strip a {
1434
- padding: 6px 10px;
1435
- background: var(--paper-deep);
1436
- color: var(--muted);
1437
- font-size: 11px;
1438
- border: 1px solid transparent;
1439
- }
1440
-
1441
- .topic-strip a:hover,
1442
- .topic-strip a:focus-visible {
1443
- color: var(--ink);
1444
- border-color: var(--line-strong);
1445
- }
1446
-
1447
- .topic-strip a.active {
1448
- background: var(--ink);
1449
- color: var(--paper);
1450
- }
1451
-
1452
1421
  .archive-list {
1453
1422
  border-top: 1px solid var(--line-strong);
1454
1423
  }
@@ -1496,13 +1465,6 @@ button {
1496
1465
  border: 1px solid var(--line);
1497
1466
  }
1498
1467
 
1499
- .archive-topic {
1500
- color: var(--accent);
1501
- font-size: 10px;
1502
- font-weight: 700;
1503
- letter-spacing: 0.05em;
1504
- }
1505
-
1506
1468
  .pinned-badge {
1507
1469
  margin-left: 9px;
1508
1470
  display: inline-flex;
@@ -2089,12 +2051,14 @@ button {
2089
2051
 
2090
2052
  .markdown-body a {
2091
2053
  text-decoration: underline;
2092
- text-decoration-color: var(--accent);
2054
+ text-decoration-color: color-mix(in srgb, var(--ink) 35%, transparent);
2093
2055
  text-underline-offset: 4px;
2094
2056
  }
2095
2057
 
2058
+ /* Links stay ink; hover darkens and thickens the underline instead. */
2096
2059
  .markdown-body a:hover {
2097
- color: var(--accent);
2060
+ text-decoration-color: var(--ink);
2061
+ text-decoration-thickness: 2px;
2098
2062
  }
2099
2063
 
2100
2064
  /* A per-line border stays continuous across inline code and descenders. */
@@ -2186,25 +2150,6 @@ button {
2186
2150
  color: var(--accent);
2187
2151
  }
2188
2152
 
2189
- .markdown-body table {
2190
- width: 100%;
2191
- margin: 34px 0;
2192
- border-collapse: collapse;
2193
- font-size: 12px;
2194
- }
2195
-
2196
- .markdown-body th,
2197
- .markdown-body td {
2198
- padding: 11px 13px;
2199
- border: 1px solid var(--line);
2200
- text-align: left;
2201
- }
2202
-
2203
- .markdown-body th {
2204
- background: var(--paper-deep);
2205
- color: var(--ink);
2206
- }
2207
-
2208
2153
  .markdown-body pre {
2209
2154
  position: relative;
2210
2155
  margin: 32px 0;
@@ -2879,25 +2824,10 @@ button {
2879
2824
  color: var(--accent);
2880
2825
  }
2881
2826
 
2882
- .article-references + .article-tags,
2883
2827
  .article-references + .next-entry {
2884
2828
  margin-top: 36px;
2885
2829
  }
2886
2830
 
2887
- .article-tags {
2888
- display: flex;
2889
- flex-wrap: wrap;
2890
- gap: 8px;
2891
- margin: 68px 0 24px;
2892
- }
2893
-
2894
- .article-tags span {
2895
- padding: 4px 8px;
2896
- background: var(--paper-deep);
2897
- color: var(--muted);
2898
- font-size: 10px;
2899
- }
2900
-
2901
2831
  .webmentions,
2902
2832
  .comments {
2903
2833
  margin-top: 78px;
@@ -3078,7 +3008,7 @@ button {
3078
3008
  margin: 0 auto;
3079
3009
  padding: 58px 0 120px;
3080
3010
  display: grid;
3081
- grid-template-columns: var(--column-sidebar-width) minmax(0, 700px) var(--column-sidebar-width);
3011
+ grid-template-columns: var(--column-sidebar-width) minmax(0, var(--content)) var(--column-sidebar-width);
3082
3012
  gap: 54px;
3083
3013
  justify-content: center;
3084
3014
  }
@@ -3697,12 +3627,6 @@ button {
3697
3627
  color: var(--accent);
3698
3628
  }
3699
3629
 
3700
- .footer-framework a {
3701
- text-decoration: underline;
3702
- text-decoration-color: var(--line-strong);
3703
- text-underline-offset: 3px;
3704
- }
3705
-
3706
3630
  @media (max-width: 1080px) {
3707
3631
  .post-reading-page {
3708
3632
  --toc-width: 210px;
@@ -3917,7 +3841,7 @@ button {
3917
3841
  }
3918
3842
 
3919
3843
  .column-reading-page {
3920
- grid-template-columns: minmax(0, 700px);
3844
+ grid-template-columns: minmax(0, var(--content));
3921
3845
  }
3922
3846
 
3923
3847
  .series-nav {
@@ -4202,8 +4126,7 @@ button {
4202
4126
  gap: 4px 9px;
4203
4127
  }
4204
4128
 
4205
- .archive-meta time,
4206
- .archive-topic {
4129
+ .archive-meta time {
4207
4130
  font-size: 9px;
4208
4131
  }
4209
4132
 
@@ -4274,11 +4197,6 @@ button {
4274
4197
  font-size: 17px;
4275
4198
  }
4276
4199
 
4277
- .markdown-body table {
4278
- display: block;
4279
- overflow-x: auto;
4280
- }
4281
-
4282
4200
  .markdown-body .external-embed-card,
4283
4201
  .markdown-body .external-embed-card:has(> img) {
4284
4202
  grid-template-columns: 1fr 22px;
@@ -13,6 +13,16 @@ export function demoDateLabel(value: string) {
13
13
  return `${date.getUTCFullYear()} 年 ${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日`;
14
14
  }
15
15
 
16
+ /** Home list label. The current Shanghai year drops the year so the column stays narrow. */
17
+ export function homeListDate(value: string, now = new Date()) {
18
+ const date = shanghaiDate(value);
19
+ if (!date) return value;
20
+ const today = shanghaiDate(now.toISOString());
21
+ const monthDay = `${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日`;
22
+ if (today && date.getUTCFullYear() === today.getUTCFullYear()) return monthDay;
23
+ return `${date.getUTCFullYear()} 年 ${monthDay}`;
24
+ }
25
+
16
26
  export function demoDateTime(value: string) {
17
27
  const date = shanghaiDate(value);
18
28
  if (!date) return value.slice(0, 10);
@@ -17,7 +17,6 @@ export type A2Article = {
17
17
  title: string;
18
18
  description: string;
19
19
  date: string;
20
- topic: string;
21
20
  cover: string;
22
21
  original: string;
23
22
  content: string;
@@ -42,7 +41,6 @@ export function readA2Article(slug: string): A2Article | null {
42
41
  title: String(data.title ?? slug),
43
42
  description: String(data.description ?? ""),
44
43
  date,
45
- topic: String(data.topic ?? ""),
46
44
  cover: cover && !/^https?:/iu.test(cover) ? `https://hedon.top/media/covers/${cover}` : cover,
47
45
  original: originals[slug] ?? "",
48
46
  content: parsed.content,