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.
- package/.agents/skills/garden-interactive-chart/SKILL.md +10 -10
- package/.agents/skills/garden-ui/SKILL.md +4 -4
- package/Makefile +2 -3
- package/app/a2/[slug]/page.tsx +0 -1
- package/app/a2/page.tsx +1 -1
- package/app/about/page.tsx +8 -12
- package/app/admin/page.tsx +2 -2
- package/app/blog/[...path]/page.tsx +2 -13
- package/app/blog/page.tsx +2 -2
- package/app/columns/[column]/[...path]/page.tsx +2 -4
- package/app/columns/page.tsx +2 -2
- package/app/components/AdminDashboard.tsx +3 -3
- package/app/components/ArticleMeasure.tsx +114 -42
- package/app/components/ArticleTags.tsx +17 -0
- package/app/components/BlogArchive.tsx +85 -33
- package/app/components/ContentEmptyState.tsx +6 -6
- package/app/components/GardenAdminGuide.tsx +1 -1
- package/app/components/GardenContentGuide.tsx +1 -2
- package/app/components/GardenMediaGuide.tsx +3 -3
- package/app/components/GithubProfile.tsx +1 -1
- package/app/components/MarkdownImage.tsx +4 -2
- package/app/components/SiteFooter.tsx +5 -5
- package/app/components/SiteHeader.tsx +1 -5
- package/app/components/TagRedirect.tsx +17 -0
- package/app/garden/page.tsx +6 -6
- package/app/garden-modules.d.ts +0 -1
- package/app/globals.css +19 -101
- package/app/lib/a2-meta.ts +10 -0
- package/app/lib/a2-preview.ts +0 -2
- package/app/lib/content.ts +14 -8
- package/app/lib/fallback-site-config.ts +15 -15
- package/app/lib/generated-content.ts +58 -47
- package/app/lib/tag-filter.ts +114 -0
- package/app/not-found.tsx +13 -0
- package/app/page.tsx +10 -27
- package/app/styles/a2.css +96 -15
- package/app/styles/shell.css +56 -61
- package/app/styles/tokens.css +35 -22
- package/app/tags/page.tsx +20 -9
- package/package.json +1 -1
- package/scripts/build-content.mjs +3 -3
- package/scripts/check-post.mjs +0 -3
- package/scripts/new-post.mjs +1 -3
- package/site.config.yaml +15 -15
- package/templates/site/Makefile +2 -3
- package/templates/site/site.config.yaml +15 -15
- package/ui/garden/check.mjs +2 -4
- package/ui/garden/enhance.mjs +57 -0
- package/ui/garden/title.mjs +5 -8
- package/ui/host.mjs +13 -13
- package/ui/preview/dist/garden-ui.css +11 -11
- package/ui/preview/dist/garden-ui.esm.js +6 -6
- package/ui/preview/dist/garden-ui.iife.js +7 -7
- package/ui/react/components/media.tsx +2 -2
- package/ui/react/ui.css +11 -11
- package/app/components/TagIndex.tsx +0 -97
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: garden-interactive-chart
|
|
3
3
|
description: >-
|
|
4
4
|
Writes Garden interactive charts as self-contained HTML in a same-name
|
|
5
|
-
sidecar directory and embeds them with a short
|
|
5
|
+
sidecar directory and embeds them with a short garden html fence. Use when adding
|
|
6
6
|
or editing Garden widgets, steppers, inspect grids, param benches,
|
|
7
7
|
compare views, traces, KV cache charts, or any 可交互图表 / 同名目录 HTML.
|
|
8
8
|
---
|
|
@@ -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/
|
|
38
|
-
content/posts/
|
|
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
|
-
```
|
|
45
|
-
src: ./
|
|
46
|
-
|
|
44
|
+
```garden html
|
|
45
|
+
src: ./windowsill-herbs/layers.html
|
|
46
|
+
title: 水从土面渗到排水孔,大约要一分钟。
|
|
47
47
|
```
|
|
48
48
|
````
|
|
49
49
|
|
|
@@ -51,8 +51,8 @@ caption: 朴素 decode 每步重算全部过去 token;6 个 token 共 21 次
|
|
|
51
51
|
|
|
52
52
|
- `src` 必须是该同名目录里的本地 `.html` 或 `.pdf`,不能指到 `assets/`、别的文章或 `https://`
|
|
53
53
|
- 带 `garden-chart` 高度回报的 HTML 是图表,直接铺开;普通 HTML 和 PDF 默认收成示意入口,读者展开后再加载
|
|
54
|
-
- `
|
|
55
|
-
- 一份 HTML 一张图;文件名用 `
|
|
54
|
+
- `title` 必须是判断,不能是「见下图」
|
|
55
|
+
- 一份 HTML 一张图;文件名用 `layers.html`、`diagram.html` 这种短 kebab
|
|
56
56
|
- HTML 自包含:CSS、SVG、脚本都在这一个文件里
|
|
57
57
|
- 先写 HTML,再写围栏;围栏里不要再摊步进数组或采样点
|
|
58
58
|
|
|
@@ -77,8 +77,8 @@ caption: 朴素 decode 每步重算全部过去 token;6 个 token 共 21 次
|
|
|
77
77
|
|
|
78
78
|
## 交稿前
|
|
79
79
|
|
|
80
|
-
- [ ] 文件在同名目录内,围栏只有 `src` + `
|
|
80
|
+
- [ ] 文件在同名目录内,围栏只有 `src` + `title`
|
|
81
81
|
- [ ] 单独打开 HTML 就能用;iframe 能撑开高度
|
|
82
82
|
- [ ] 浅色 / 深色主题都跟 Garden 纸面
|
|
83
83
|
- [ ] 减少动态时不自动循环
|
|
84
|
-
- [ ] `
|
|
84
|
+
- [ ] `title` 让关 JS 的人也能读到结论
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: garden-ui
|
|
3
3
|
description: >-
|
|
4
|
-
Compiles the intent blocks in one Garden post into ui blocks (or prose, or
|
|
5
|
-
|
|
4
|
+
Compiles the intent blocks in one Garden post into ui blocks (or prose, or
|
|
5
|
+
garden html), recompiles stale or broken ui blocks, and suggests where a post
|
|
6
6
|
could use interaction. Use when the user says 编译这篇 / 编译意图块 / 重编译
|
|
7
7
|
<id> / 这篇哪里适合交互, or pastes a garden ui check fix prompt.
|
|
8
8
|
---
|
|
@@ -43,7 +43,7 @@ description: >-
|
|
|
43
43
|
3. 每块读原话和所在小节,三选一:
|
|
44
44
|
- **写回正文**:一句话、一张表或一张 Mermaid 就能说清时。
|
|
45
45
|
- **写 `ui`**:读者需要拖、点、翻才能看懂时。
|
|
46
|
-
- **写 `
|
|
46
|
+
- **写 `garden html`**:目录表达不了的画布动画、音频、物理模拟等。按 [garden-interactive-chart](../garden-interactive-chart/SKILL.md) 在同名目录写 HTML,用 `title` 写下判断。
|
|
47
47
|
|
|
48
48
|
4. 写 `ui` 时:
|
|
49
49
|
- 原话原样放进块头的 `intent`,一个字都不改。`compiled` 不用写,`apply` 会写。
|
|
@@ -69,7 +69,7 @@ description: >-
|
|
|
69
69
|
````
|
|
70
70
|
|
|
71
71
|
- 没写 id 的 `intent` 块用 `--intent-hash <intentHash>` 定位;原话重复时改用 `--at <startLine>`。
|
|
72
|
-
- 新内容可以是一个 `ui` 围栏、一个 `
|
|
72
|
+
- 新内容可以是一个 `ui` 围栏、一个 `garden html` 围栏,或一段普通 Markdown(写回正文时)。
|
|
73
73
|
- 成功时 stdout 是 `{"ok":true,...}`。失败时 stderr 是 JSON,按 `code` 处理:
|
|
74
74
|
- `apply-invalid`:新内容没通过校验,按 `diagnostics` 改了再写。
|
|
75
75
|
- `apply-base`:块在扫描之后被改过。重新 `scan`,用新的 `hash`。
|
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
|
|
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
|
|
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
|
package/app/a2/[slug]/page.tsx
CHANGED
|
@@ -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"
|
|
15
|
+
<p className="sh-deck">对照参考文章的版式。正文没有改字。</p>
|
|
16
16
|
</header>
|
|
17
17
|
<ul className="sh-rows">
|
|
18
18
|
{articles.map((article) => (
|
package/app/about/page.tsx
CHANGED
|
@@ -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
|
-
"
|
|
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="
|
|
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>
|
package/app/admin/page.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -99,19 +100,14 @@ export default async function PostPage({
|
|
|
99
100
|
</time>
|
|
100
101
|
</li>
|
|
101
102
|
{post.updated ? <li>更新于 {formatDate(post.updated)}</li> : null}
|
|
102
|
-
<li>{post.readingTime}</li>
|
|
103
103
|
<li>约 {wordCount.toLocaleString("en-US")} 字</li>
|
|
104
|
-
{post.topic ? (
|
|
105
|
-
<li>
|
|
106
|
-
<a className="cat" href={`/blog?topic=${encodeURIComponent(post.topic)}`}>{post.topic}</a>
|
|
107
|
-
</li>
|
|
108
|
-
) : null}
|
|
109
104
|
{originalUrl(post) ? (
|
|
110
105
|
<li>
|
|
111
106
|
<a className="orig ext" href={originalUrl(post)} target="_blank" rel="noopener noreferrer">原文 ↗</a>
|
|
112
107
|
</li>
|
|
113
108
|
) : null}
|
|
114
109
|
</ul>
|
|
110
|
+
<ArticleTags tags={post.tags} />
|
|
115
111
|
{post.cover && (
|
|
116
112
|
<figure className="article-hero article-cover cover-card cover-img">
|
|
117
113
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
@@ -127,13 +123,6 @@ export default async function PostPage({
|
|
|
127
123
|
<MarkdownArticle content={post.content} />
|
|
128
124
|
</div>
|
|
129
125
|
<ArticleReferences references={post.references} />
|
|
130
|
-
<div className="article-tags">
|
|
131
|
-
{post.tags?.map((tag) => (
|
|
132
|
-
<span className="p-category" key={tag}>
|
|
133
|
-
#{tag}
|
|
134
|
-
</span>
|
|
135
|
-
))}
|
|
136
|
-
</div>
|
|
137
126
|
<Webmentions
|
|
138
127
|
target={canonicalUrl}
|
|
139
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,14 +123,11 @@ export default async function ColumnEntryPage({
|
|
|
122
123
|
{formatDate(entry.date)}
|
|
123
124
|
</time>
|
|
124
125
|
</li>
|
|
125
|
-
<li>{entry.readingTime}</li>
|
|
126
|
-
{entry.topic && entry.topic !== entry.columnTitle ? (
|
|
127
|
-
<li><a className="cat" href={`/blog?topic=${encodeURIComponent(entry.topic)}`}>{entry.topic}</a></li>
|
|
128
|
-
) : null}
|
|
129
126
|
{originalUrl(entry) ? (
|
|
130
127
|
<li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">原文 ↗</a></li>
|
|
131
128
|
) : null}
|
|
132
129
|
</ul>
|
|
130
|
+
<ArticleTags tags={entry.tags} />
|
|
133
131
|
{entry.cover && (
|
|
134
132
|
<figure className="article-hero article-cover cover-img">
|
|
135
133
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
package/app/columns/page.tsx
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
window.addEventListener("resize",
|
|
81
|
-
window.addEventListener("scroll",
|
|
82
|
-
window.addEventListener("load",
|
|
83
|
-
const observer = new ResizeObserver(
|
|
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")
|
|
154
|
+
const images = [...article.querySelectorAll("img")];
|
|
155
|
+
images.forEach((image) => image.addEventListener("load", refresh));
|
|
86
156
|
return () => {
|
|
87
|
-
window.removeEventListener("resize",
|
|
88
|
-
window.removeEventListener("scroll",
|
|
89
|
-
window.removeEventListener("load",
|
|
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 (
|
|
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
|
+
}
|