hd-garden 0.4.1 → 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 (55) hide show
  1. package/.agents/skills/garden-interactive-chart/SKILL.md +5 -5
  2. package/Makefile +2 -3
  3. package/app/a2/[slug]/page.tsx +0 -1
  4. package/app/a2/page.tsx +1 -1
  5. package/app/about/page.tsx +8 -12
  6. package/app/admin/page.tsx +2 -2
  7. package/app/blog/[...path]/page.tsx +2 -12
  8. package/app/blog/page.tsx +2 -2
  9. package/app/columns/[column]/[...path]/page.tsx +2 -3
  10. package/app/columns/page.tsx +2 -2
  11. package/app/components/AdminDashboard.tsx +3 -3
  12. package/app/components/ArticleMeasure.tsx +114 -42
  13. package/app/components/ArticleTags.tsx +17 -0
  14. package/app/components/BlogArchive.tsx +85 -33
  15. package/app/components/ContentEmptyState.tsx +6 -6
  16. package/app/components/GardenAdminGuide.tsx +1 -1
  17. package/app/components/GardenContentGuide.tsx +1 -2
  18. package/app/components/GardenMediaGuide.tsx +3 -3
  19. package/app/components/GithubProfile.tsx +1 -1
  20. package/app/components/MarkdownImage.tsx +4 -2
  21. package/app/components/SiteFooter.tsx +5 -5
  22. package/app/components/SiteHeader.tsx +1 -5
  23. package/app/components/TagRedirect.tsx +17 -0
  24. package/app/garden/page.tsx +6 -6
  25. package/app/garden-modules.d.ts +0 -1
  26. package/app/globals.css +19 -101
  27. package/app/lib/a2-meta.ts +10 -0
  28. package/app/lib/a2-preview.ts +0 -2
  29. package/app/lib/content.ts +10 -7
  30. package/app/lib/fallback-site-config.ts +15 -15
  31. package/app/lib/generated-content.ts +58 -47
  32. package/app/lib/tag-filter.ts +114 -0
  33. package/app/not-found.tsx +13 -0
  34. package/app/page.tsx +9 -26
  35. package/app/styles/a2.css +96 -15
  36. package/app/styles/shell.css +54 -59
  37. package/app/styles/tokens.css +35 -22
  38. package/app/tags/page.tsx +20 -9
  39. package/package.json +1 -1
  40. package/scripts/build-content.mjs +3 -3
  41. package/scripts/check-post.mjs +0 -3
  42. package/scripts/new-post.mjs +1 -3
  43. package/site.config.yaml +15 -15
  44. package/templates/site/Makefile +2 -3
  45. package/templates/site/site.config.yaml +15 -15
  46. package/ui/garden/check.mjs +2 -4
  47. package/ui/garden/enhance.mjs +57 -0
  48. package/ui/garden/title.mjs +5 -8
  49. package/ui/host.mjs +13 -13
  50. package/ui/preview/dist/garden-ui.css +11 -11
  51. package/ui/preview/dist/garden-ui.esm.js +6 -6
  52. package/ui/preview/dist/garden-ui.iife.js +7 -7
  53. package/ui/react/components/media.tsx +2 -2
  54. package/ui/react/ui.css +11 -11
  55. package/app/components/TagIndex.tsx +0 -97
@@ -34,16 +34,16 @@ description: >-
34
34
  Markdown 在 `content/posts/<dir>/<slug>.md` 时,图表只能放在 `content/posts/<dir>/<slug>/`:
35
35
 
36
36
  ```text
37
- content/posts/understand-kv-cache.md
38
- content/posts/understand-kv-cache/waste.html
37
+ content/posts/windowsill-herbs.md
38
+ content/posts/windowsill-herbs/layers.html
39
39
  ```
40
40
 
41
41
  正文:
42
42
 
43
43
  ````md
44
44
  ```garden html
45
- src: ./understand-kv-cache/waste.html
46
- title: 朴素 decode 每步重算全部过去 token;6 个 token 共 21 次计算,其中 15 次是浪费。
45
+ src: ./windowsill-herbs/layers.html
46
+ title: 水从土面渗到排水孔,大约要一分钟。
47
47
  ```
48
48
  ````
49
49
 
@@ -52,7 +52,7 @@ title: 朴素 decode 每步重算全部过去 token;6 个 token 共 21 次计
52
52
  - `src` 必须是该同名目录里的本地 `.html` 或 `.pdf`,不能指到 `assets/`、别的文章或 `https://`
53
53
  - 带 `garden-chart` 高度回报的 HTML 是图表,直接铺开;普通 HTML 和 PDF 默认收成示意入口,读者展开后再加载
54
54
  - `title` 必须是判断,不能是「见下图」
55
- - 一份 HTML 一张图;文件名用 `waste.html`、`stepper.html` 这种短 kebab
55
+ - 一份 HTML 一张图;文件名用 `layers.html`、`diagram.html` 这种短 kebab
56
56
  - HTML 自包含:CSS、SVG、脚本都在这一个文件里
57
57
  - 先写 HTML,再写围栏;围栏里不要再摊步进数组或采样点
58
58
 
package/Makefile CHANGED
@@ -5,15 +5,14 @@ MESSAGE ?= 更新博客
5
5
  .PHONY: help new embed skill prepare dev check build update
6
6
 
7
7
  # Pass literal user input via environment, never interpolate it into shell code.
8
- unexport TITLE SLUG DIR TOPIC URL
8
+ unexport TITLE SLUG DIR URL
9
9
  export GARDEN_NEW_TITLE := $(value TITLE)
10
10
  export GARDEN_NEW_SLUG := $(value SLUG)
11
11
  export GARDEN_NEW_DIR := $(value DIR)
12
- export GARDEN_NEW_TOPIC := $(value TOPIC)
13
12
  export GARDEN_EMBED_URL := $(value URL)
14
13
 
15
14
  help:
16
- @printf "%s\n" "Garden 常用命令:" " make new 创建文章草稿(无需安装 npm 依赖)" ' make new TITLE="文章标题" SLUG=my-post DIR=writing TOPIC="写作"' ' make embed URL="https://example.com" 拉取外部网页摘要,打印可粘贴的 embed 围栏' " make skill 安装可交互图表 skill" " make dev 首次准备并本地预览" " make check 检查内容与测试" " make build 构建公开站点" " make update 检查、提交并触发自动发布" " make update MESSAGE=\"新增一篇文章\" 自定义提交说明"
15
+ @printf "%s\n" "Garden 常用命令:" " make new 创建文章草稿(无需安装 npm 依赖)" ' make new TITLE="文章标题" SLUG=my-post DIR=writing' ' make embed URL="https://example.com" 拉取外部网页摘要,打印可粘贴的 embed 围栏' " make skill 安装可交互图表 skill" " make dev 首次准备并本地预览" " make check 检查内容与测试" " make build 构建公开站点" " make update 检查、提交并触发自动发布" " make update MESSAGE=\"新增一篇文章\" 自定义提交说明"
17
16
 
18
17
  new:
19
18
  @node scripts/new-post.mjs
@@ -35,7 +35,6 @@ export default async function A2ArticlePage({ params }: { params: Promise<{ slug
35
35
  <li>Hedon</li>
36
36
  {article.date ? <li><time dateTime={demoDateTime(article.date)}>{demoDateLabel(article.date)}</time></li> : null}
37
37
  <li>约 {wordCount.toLocaleString("en-US")} 字</li>
38
- {article.topic ? <li><span className="cat">{article.topic}</span></li> : null}
39
38
  {article.original ? <li><a className="orig ext" href={article.original} target="_blank" rel="noopener noreferrer">原文 ↗</a></li> : null}
40
39
  </ul>
41
40
  {article.cover ? (
package/app/a2/page.tsx CHANGED
@@ -12,7 +12,7 @@ export default function A2Index() {
12
12
  <main className="page-shell index-page">
13
13
  <header className="sh-head">
14
14
  <h1>A2 对照</h1>
15
- <p className="sh-deck">用这套组件渲染参考文章,对照线上演示。正文没有改字。</p>
15
+ <p className="sh-deck">对照参考文章的版式。正文没有改字。</p>
16
16
  </header>
17
17
  <ul className="sh-rows">
18
18
  {articles.map((article) => (
@@ -5,18 +5,14 @@ import { PageIntro } from "../components/PageIntro";
5
5
  import { siteConfig } from "../site.config";
6
6
 
7
7
  const about = siteConfig.pages.about;
8
- const heading = about.heading ?? "这个网站为什么存在";
8
+ const heading = about.heading ?? "关于";
9
9
  const paragraphs = about.paragraphs ?? [
10
- "我需要一个不被信息流推着走的地方。长文可以慢慢展开,随想可以轻轻放下;同一个主题也可以沿着清晰的顺序持续生长。",
11
- "所有文章都从本地 Markdown 文件开始。这意味着我可以继续在 Typora 里写作,也意味着内容不会被某个编辑器或平台锁住。",
10
+ "一名工程师。平时写代码,也把想清楚的问题记下来。",
11
+ "这里放文章、随想和专栏。",
12
12
  ];
13
- const quote = about.quote ?? "写作不是把已经想明白的东西记下来,而是在句子之间发现自己还没有想明白什么。";
14
- const focus = about.focus ?? [
15
- "将自己的关注方向写在这里",
16
- "用文章积累可迁移的知识",
17
- "为长期写作留出稳定的空间",
18
- ];
19
- const cta = about.cta ?? { href: "/blog", label: "从博文开始" };
13
+ const quote = about.quote ?? "";
14
+ const focus = about.focus ?? ["软件", "笔记", "日常"];
15
+ const cta = about.cta ?? { href: "/blog", label: "浏览博文" };
20
16
 
21
17
  export const metadata = {
22
18
  title: { absolute: siteConfig.name },
@@ -27,7 +23,7 @@ export default function AboutPage() {
27
23
  return (
28
24
  <main className="page-shell about-page">
29
25
  <PageIntro
30
- eyebrow="ABOUT / 关于"
26
+ eyebrow="关于"
31
27
  title={about.title}
32
28
  subtitle={about.subtitle}
33
29
  />
@@ -38,7 +34,7 @@ export default function AboutPage() {
38
34
  {paragraphs.map((paragraph) => (
39
35
  <p key={paragraph}>{paragraph}</p>
40
36
  ))}
41
- <blockquote>{quote}</blockquote>
37
+ {quote ? <blockquote>{quote}</blockquote> : null}
42
38
  </section>
43
39
  <aside className="about-facts">
44
40
  <span>现在关注</span>
@@ -92,7 +92,7 @@ export default async function AdminPage({
92
92
  slug: post.slug,
93
93
  path: post.path,
94
94
  title: post.title,
95
- topic: post.topic ?? "博文",
95
+ section: "文章",
96
96
  pinned: Boolean(post.pinned),
97
97
  })),
98
98
  ...columns.map((entry) => ({
@@ -101,7 +101,7 @@ export default async function AdminPage({
101
101
  slug: entry.slug,
102
102
  path: entry.path,
103
103
  title: entry.title,
104
- topic: entry.columnTitle ?? "专栏",
104
+ section: entry.columnTitle ?? "专栏",
105
105
  })),
106
106
  ];
107
107
 
@@ -16,6 +16,7 @@ import { MarkdownArticle } from "../../components/MarkdownArticle";
16
16
  import { TableOfContents } from "../../components/TableOfContents";
17
17
  import { Comments } from "../../components/Comments";
18
18
  import { Webmentions } from "../../components/Webmentions";
19
+ import { ArticleTags } from "../../components/ArticleTags";
19
20
  import {
20
21
  absoluteUrl,
21
22
  getSiteUrl,
@@ -100,17 +101,13 @@ export default async function PostPage({
100
101
  </li>
101
102
  {post.updated ? <li>更新于 {formatDate(post.updated)}</li> : null}
102
103
  <li>约 {wordCount.toLocaleString("en-US")} 字</li>
103
- {post.topic ? (
104
- <li>
105
- <a className="cat" href={`/blog?topic=${encodeURIComponent(post.topic)}`}>{post.topic}</a>
106
- </li>
107
- ) : null}
108
104
  {originalUrl(post) ? (
109
105
  <li>
110
106
  <a className="orig ext" href={originalUrl(post)} target="_blank" rel="noopener noreferrer">原文 ↗</a>
111
107
  </li>
112
108
  ) : null}
113
109
  </ul>
110
+ <ArticleTags tags={post.tags} />
114
111
  {post.cover && (
115
112
  <figure className="article-hero article-cover cover-card cover-img">
116
113
  {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -126,13 +123,6 @@ export default async function PostPage({
126
123
  <MarkdownArticle content={post.content} />
127
124
  </div>
128
125
  <ArticleReferences references={post.references} />
129
- <div className="article-tags">
130
- {post.tags?.map((tag) => (
131
- <span className="p-category" key={tag}>
132
- #{tag}
133
- </span>
134
- ))}
135
- </div>
136
126
  <Webmentions
137
127
  target={canonicalUrl}
138
128
  endpoint={webmentionApiEndpoint()}
package/app/blog/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Suspense } from "react";
2
2
  import { siteConfig, siteDocumentTitle } from "../site.config";
3
- import { BlogArchive } from "../components/BlogArchive";
3
+ import { BlogArchive, BlogArchiveFallback } from "../components/BlogArchive";
4
4
  import { posts } from "../lib/content";
5
5
 
6
6
  export const metadata = {
@@ -15,7 +15,7 @@ export default function BlogIndex() {
15
15
  <h1 id="sh-title">文章</h1>
16
16
  <p className="sh-deck">阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</p>
17
17
  </div>
18
- <Suspense>
18
+ <Suspense fallback={<BlogArchiveFallback />}>
19
19
  <BlogArchive />
20
20
  </Suspense>
21
21
  </main>
@@ -17,6 +17,7 @@ import { MarkdownArticle } from "../../../components/MarkdownArticle";
17
17
  import { TableOfContents } from "../../../components/TableOfContents";
18
18
  import { Comments } from "../../../components/Comments";
19
19
  import { Webmentions } from "../../../components/Webmentions";
20
+ import { ArticleTags } from "../../../components/ArticleTags";
20
21
  import {
21
22
  absoluteUrl,
22
23
  getSiteUrl,
@@ -122,13 +123,11 @@ export default async function ColumnEntryPage({
122
123
  {formatDate(entry.date)}
123
124
  </time>
124
125
  </li>
125
- {entry.topic && entry.topic !== entry.columnTitle ? (
126
- <li><a className="cat" href={`/blog?topic=${encodeURIComponent(entry.topic)}`}>{entry.topic}</a></li>
127
- ) : null}
128
126
  {originalUrl(entry) ? (
129
127
  <li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">原文 ↗</a></li>
130
128
  ) : null}
131
129
  </ul>
130
+ <ArticleTags tags={entry.tags} />
132
131
  {entry.cover && (
133
132
  <figure className="article-hero article-cover cover-img">
134
133
  {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -6,7 +6,7 @@ import { siteConfig, siteDocumentTitle } from "../site.config";
6
6
 
7
7
  export const metadata = {
8
8
  title: siteDocumentTitle,
9
- description: "专栏是按主题整理的阅读清单,与文章分类相互独立;清单内按发布时间从新到旧排列。",
9
+ description: siteConfig.pages.columns.subtitle || siteConfig.description,
10
10
  };
11
11
 
12
12
  function day(value: string) {
@@ -38,7 +38,7 @@ export default function ColumnsPage() {
38
38
  <main className="sh-main" id="content">
39
39
  <div className="sh-head">
40
40
  <h1>{siteConfig.pages.columns.title || "专栏"}</h1>
41
- <p className="sh-deck">专栏是按主题整理的阅读清单,与文章分类相互独立;清单内按发布时间从新到旧排列。</p>
41
+ {siteConfig.pages.columns.subtitle ? <p className="sh-deck">{siteConfig.pages.columns.subtitle}</p> : null}
42
42
  </div>
43
43
  {grouped.length ? grouped.map(({ slug, first, entries }) => (
44
44
  <section className="sh-series" aria-labelledby={slug} key={slug}>
@@ -25,7 +25,7 @@ type CatalogEntry = {
25
25
  slug: string;
26
26
  path: string;
27
27
  title: string;
28
- topic: string;
28
+ section: string;
29
29
  pinned?: boolean;
30
30
  };
31
31
 
@@ -327,7 +327,7 @@ export function AdminDashboard({
327
327
 
328
328
  async function copyPackage(entry: CatalogEntry) {
329
329
  const path = entry.kind === "post" ? `/blog/${entry.path}` : `/columns/${entry.column}/${entry.path}`;
330
- await navigator.clipboard.writeText(`${entry.title}\n${entry.topic}\n${publicSiteUrl}${path}`);
330
+ await navigator.clipboard.writeText(`${entry.title}\n${entry.section}\n${publicSiteUrl}${path}`);
331
331
  setNotice("标题、栏目与公开链接已复制,可直接粘贴到任意发布平台。"
332
332
  );
333
333
  }
@@ -431,7 +431,7 @@ export function AdminDashboard({
431
431
  <div className="admin-catalog">
432
432
  {catalogEntries.map((entry) => (
433
433
  <article key={`${entry.kind}-${entry.column ?? ""}-${entry.path}`}>
434
- <div><span>{entry.kind === "post" ? `博文${entry.pinned ? " · 已置顶" : ""}` : "专栏"} · {entry.topic}</span><h3>{entry.title}</h3></div>
434
+ <div><span>{entry.kind === "post" ? `博文${entry.pinned ? " · 已置顶" : ""}` : "专栏"} · {entry.section}</span><h3>{entry.title}</h3></div>
435
435
  <div className="admin-distribution-actions">
436
436
  {entry.kind === "post" && (
437
437
  <button type="button" disabled={pinningPath === entry.path} onClick={() => updatePinned(entry)}>
@@ -1,51 +1,57 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect } from "react";
4
- import { tableBreakout, tocShift } from "../../ui/garden/enhance.mjs";
4
+ import { figureBreakout, figureImageWidth, tableBreakout, tocShift } from "../../ui/garden/enhance.mjs";
5
5
 
6
6
  function translatedX(element: Element) {
7
7
  const value = new DOMMatrix(getComputedStyle(element).transform).m41;
8
8
  return Number.isFinite(value) ? value : 0;
9
9
  }
10
10
 
11
+ // The table is width:100% and wraps its cells, so scrollWidth never exceeds
12
+ // the column. Measure the unwrapped width instead.
13
+ function naturalWidth(table: HTMLTableElement) {
14
+ const previous = table.style.width;
15
+ table.style.width = "max-content";
16
+ const width = table.scrollWidth;
17
+ table.style.width = previous;
18
+ return width;
19
+ }
20
+
21
+ function resetWide(element: HTMLElement) {
22
+ element.classList.remove("ux-wide");
23
+ element.style.removeProperty("--wl");
24
+ element.style.removeProperty("--wr");
25
+ }
26
+
11
27
  export function ArticleMeasure() {
12
28
  useEffect(() => {
13
29
  const article = document.querySelector("article");
14
30
  const toc = document.querySelector<HTMLElement>(".toc");
15
31
  if (!article) return undefined;
16
32
  const root = document.documentElement;
17
- let frame = 0;
33
+ // Natural content widths are expensive (a max-content relayout per table),
34
+ // so they are cached and only refreshed on resize and load.
35
+ const natural = new WeakMap<HTMLElement, number>();
36
+ let measureFrame = 0;
37
+ let tocFrame = 0;
38
+ let refreshPending = false;
18
39
 
19
- const layout = () => {
20
- const articleBox = article.getBoundingClientRect();
21
- const viewport = root.clientWidth;
22
- article.querySelectorAll<HTMLElement>(".table-wrap").forEach((wrap) => {
23
- const table = wrap.querySelector("table");
24
- wrap.classList.remove("ux-wide");
25
- wrap.style.removeProperty("--wl");
26
- wrap.style.removeProperty("--wr");
27
- if (!table) return;
28
- const margin = tableBreakout({
29
- viewport,
30
- articleLeft: articleBox.left,
31
- articleRight: articleBox.right,
32
- tableClient: wrap.clientWidth,
33
- tableScroll: table.scrollWidth,
34
- });
35
- if (margin.left > 0) {
36
- wrap.style.setProperty("--wl", `${margin.left}px`);
37
- wrap.style.setProperty("--wr", `${margin.right}px`);
38
- wrap.classList.add("ux-wide");
39
- }
40
- });
40
+ const tableWidth = (wrap: HTMLElement, refresh: boolean) => {
41
+ if (!refresh && natural.has(wrap)) return natural.get(wrap) ?? 0;
42
+ const table = wrap.querySelector("table");
43
+ const width = table ? naturalWidth(table) : 0;
44
+ natural.set(wrap, width);
45
+ return width;
46
+ };
41
47
 
48
+ const placeToc = () => {
42
49
  if (!toc) {
43
50
  root.removeAttribute("data-toc-yield");
44
51
  return;
45
52
  }
46
53
  const box = toc.getBoundingClientRect();
47
- const dx = translatedX(toc);
48
- const originalLeft = box.left - dx;
54
+ const originalLeft = box.left - translatedX(toc);
49
55
  const overlapRights: number[] = [];
50
56
  article.querySelectorAll<HTMLElement>(".ux-wide").forEach((element) => {
51
57
  const wide = element.getBoundingClientRect();
@@ -55,7 +61,7 @@ export function ArticleMeasure() {
55
61
  const shift = tocShift({
56
62
  tocLeft: originalLeft,
57
63
  tocWidth: box.width,
58
- viewport,
64
+ viewport: root.clientWidth,
59
65
  overlapRights,
60
66
  });
61
67
  if (shift.shift > 0) {
@@ -68,27 +74,93 @@ export function ArticleMeasure() {
68
74
  }
69
75
  };
70
76
 
71
- const schedule = () => {
72
- if (frame) return;
73
- frame = window.requestAnimationFrame(() => {
74
- frame = 0;
75
- layout();
77
+ const measure = (refresh: boolean) => {
78
+ // Measure the text column, not the whole article grid (which includes the TOC rail).
79
+ const articleBox = (article.querySelector(".markdown-body") ?? article).getBoundingClientRect();
80
+ const viewport = root.clientWidth;
81
+ const widen = (element: HTMLElement, margin: { left: number; right: number }) => {
82
+ if (margin.left <= 0) return;
83
+ element.style.setProperty("--wl", `${margin.left}px`);
84
+ element.style.setProperty("--wr", `${margin.right}px`);
85
+ element.classList.add("ux-wide");
86
+ };
87
+ // Tables: as wide as their unwrapped content, up to 300px a side.
88
+ article.querySelectorAll<HTMLElement>(".table-wrap").forEach((wrap) => {
89
+ const width = tableWidth(wrap, refresh);
90
+ resetWide(wrap);
91
+ if (!width) return;
92
+ widen(wrap, tableBreakout({
93
+ viewport,
94
+ articleLeft: articleBox.left,
95
+ articleRight: articleBox.right,
96
+ tableClient: wrap.clientWidth,
97
+ tableScroll: width,
98
+ }));
99
+ });
100
+ // Images: as wide as they display (2x screenshots at half their pixels), up to 1040px in total.
101
+ article.querySelectorAll<HTMLElement>(".fig").forEach((figure) => {
102
+ const image = figure.querySelector("img");
103
+ resetWide(figure);
104
+ if (!image) return;
105
+ // Back to CSS sizing: natural size, capped at the column.
106
+ image.style.removeProperty("width");
107
+ if (!image.naturalWidth) return;
108
+ const attrWidth = Number(image.getAttribute("width")) || 0;
109
+ const breakout = figureBreakout({
110
+ viewport,
111
+ articleLeft: articleBox.left,
112
+ articleRight: articleBox.right,
113
+ figureClient: figure.clientWidth,
114
+ imageClient: image.clientWidth,
115
+ naturalWidth: image.naturalWidth,
116
+ attrWidth,
117
+ });
118
+ widen(figure, breakout);
119
+ // Only a widened image, or one with a width attribute, gets an inline width.
120
+ const width = figureImageWidth({ naturalWidth: image.naturalWidth, attrWidth, widened: breakout.left > 0 });
121
+ if (width) image.style.width = `${width}px`;
122
+ });
123
+ placeToc();
124
+ };
125
+
126
+ // Resize and load refresh the cached widths; the ResizeObserver reuses them.
127
+ const scheduleMeasure = (refresh: boolean) => {
128
+ refreshPending ||= refresh;
129
+ if (measureFrame) return;
130
+ measureFrame = window.requestAnimationFrame(() => {
131
+ measureFrame = 0;
132
+ const shouldRefresh = refreshPending;
133
+ refreshPending = false;
134
+ measure(shouldRefresh);
135
+ });
136
+ };
137
+ const refresh = () => scheduleMeasure(true);
138
+ const reuse = () => scheduleMeasure(false);
139
+ // Scrolling only moves wide elements past the TOC; no width is measured.
140
+ const onScroll = () => {
141
+ if (tocFrame) return;
142
+ tocFrame = window.requestAnimationFrame(() => {
143
+ tocFrame = 0;
144
+ placeToc();
76
145
  });
77
146
  };
78
147
 
79
- layout();
80
- window.addEventListener("resize", schedule);
81
- window.addEventListener("scroll", schedule, { passive: true });
82
- window.addEventListener("load", schedule);
83
- const observer = new ResizeObserver(schedule);
148
+ measure(true);
149
+ window.addEventListener("resize", refresh);
150
+ window.addEventListener("scroll", onScroll, { passive: true });
151
+ window.addEventListener("load", refresh);
152
+ const observer = new ResizeObserver(reuse);
84
153
  observer.observe(article);
85
- article.querySelectorAll("img").forEach((image) => image.addEventListener("load", schedule));
154
+ const images = [...article.querySelectorAll("img")];
155
+ images.forEach((image) => image.addEventListener("load", refresh));
86
156
  return () => {
87
- window.removeEventListener("resize", schedule);
88
- window.removeEventListener("scroll", schedule);
89
- window.removeEventListener("load", schedule);
157
+ window.removeEventListener("resize", refresh);
158
+ window.removeEventListener("scroll", onScroll);
159
+ window.removeEventListener("load", refresh);
160
+ images.forEach((image) => image.removeEventListener("load", refresh));
90
161
  observer.disconnect();
91
- if (frame) window.cancelAnimationFrame(frame);
162
+ if (measureFrame) window.cancelAnimationFrame(measureFrame);
163
+ if (tocFrame) window.cancelAnimationFrame(tocFrame);
92
164
  };
93
165
  }, []);
94
166
  return null;
@@ -0,0 +1,17 @@
1
+ import { tagFilterHref } from "../lib/tag-filter";
2
+
3
+ export function ArticleTags({ tags }: { tags?: string[] }) {
4
+ const list = (tags ?? []).filter(Boolean);
5
+ if (!list.length) return null;
6
+ return (
7
+ <ul className="article-tagrow" aria-label="标签">
8
+ {list.map((tag) => (
9
+ <li key={tag}>
10
+ <a className="tag-chip p-category" href={tagFilterHref([tag])}>
11
+ #{tag}
12
+ </a>
13
+ </li>
14
+ ))}
15
+ </ul>
16
+ );
17
+ }
@@ -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>