hd-garden 0.4.1 → 0.5.1
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 +5 -5
- 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 +4 -14
- package/app/blog/page.tsx +6 -3
- package/app/columns/[column]/[...path]/page.tsx +4 -9
- package/app/columns/page.tsx +2 -2
- package/app/components/AdminDashboard.tsx +3 -3
- package/app/components/ArticleMeasure.tsx +114 -42
- package/app/components/ArticleReferences.tsx +31 -31
- package/app/components/ArticleTags.tsx +17 -0
- package/app/components/BlogArchive.tsx +94 -50
- package/app/components/ContentEmptyState.tsx +6 -6
- package/app/components/CoverImage.tsx +34 -0
- 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/LinkCardRefresh.tsx +44 -0
- package/app/components/MarkdownArticle.tsx +87 -19
- package/app/components/MarkdownImage.tsx +4 -2
- package/app/components/SiteFooter.tsx +5 -5
- package/app/components/SiteHeader.tsx +105 -33
- package/app/components/TagRedirect.tsx +17 -0
- package/app/garden/page.tsx +6 -6
- package/app/garden-modules.d.ts +17 -2
- package/app/globals.css +92 -136
- package/app/layout.tsx +1 -7
- package/app/lib/a2-meta.ts +10 -0
- package/app/lib/a2-preview.ts +0 -2
- package/app/lib/archive.ts +43 -0
- package/app/lib/content.ts +12 -43
- package/app/lib/fallback-site-config.ts +15 -15
- package/app/lib/generated-content.ts +58 -47
- package/app/lib/heading-number.ts +9 -0
- package/app/lib/remark-link-card.mjs +83 -0
- package/app/lib/tag-filter.ts +151 -0
- package/app/not-found.tsx +13 -0
- package/app/page.tsx +12 -29
- package/app/rss.xml/route.ts +2 -1
- package/app/styles/a2.css +159 -27
- package/app/styles/shell.css +54 -59
- package/app/styles/tokens.css +35 -22
- package/app/tags/page.tsx +20 -9
- package/bin/garden.mjs +6 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +50 -9
- package/scripts/check-post.mjs +0 -3
- package/scripts/cover-images.mjs +69 -0
- package/scripts/link-assets.mjs +271 -0
- package/scripts/link-meta-core.mjs +321 -0
- 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/core/transform.mjs +4 -3
- package/ui/garden/card-refresh.mjs +92 -0
- package/ui/garden/check.mjs +2 -4
- package/ui/garden/enhance.mjs +57 -0
- package/ui/garden/markdown-links.mjs +84 -0
- package/ui/garden/references.mjs +21 -0
- package/ui/garden/search-full.mjs +131 -0
- package/ui/garden/search-index.mjs +102 -0
- package/ui/garden/search-match.mjs +96 -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 +8 -8
- package/ui/preview/dist/garden-ui.iife.js +8 -8
- package/ui/react/components/media.tsx +2 -2
- package/ui/react/ui.css +11 -11
- package/worker/og/README.md +59 -0
- package/worker/og/handler.mjs +147 -0
- package/worker/og/index.mjs +61 -0
- package/worker/og/wrangler.toml +18 -0
- 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/
|
|
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
44
|
```garden html
|
|
45
|
-
src: ./
|
|
46
|
-
title:
|
|
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 一张图;文件名用 `
|
|
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
|
|
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
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
+
import { CoverImage } from "../../components/CoverImage";
|
|
2
3
|
import type { Metadata } from "next";
|
|
3
4
|
import { notFound } from "next/navigation";
|
|
4
5
|
import {
|
|
@@ -16,6 +17,7 @@ import { MarkdownArticle } from "../../components/MarkdownArticle";
|
|
|
16
17
|
import { TableOfContents } from "../../components/TableOfContents";
|
|
17
18
|
import { Comments } from "../../components/Comments";
|
|
18
19
|
import { Webmentions } from "../../components/Webmentions";
|
|
20
|
+
import { ArticleTags } from "../../components/ArticleTags";
|
|
19
21
|
import {
|
|
20
22
|
absoluteUrl,
|
|
21
23
|
getSiteUrl,
|
|
@@ -100,21 +102,16 @@ export default async function PostPage({
|
|
|
100
102
|
</li>
|
|
101
103
|
{post.updated ? <li>更新于 {formatDate(post.updated)}</li> : null}
|
|
102
104
|
<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
105
|
{originalUrl(post) ? (
|
|
109
106
|
<li>
|
|
110
107
|
<a className="orig ext" href={originalUrl(post)} target="_blank" rel="noopener noreferrer">原文 ↗</a>
|
|
111
108
|
</li>
|
|
112
109
|
) : null}
|
|
113
110
|
</ul>
|
|
111
|
+
<ArticleTags tags={post.tags} />
|
|
114
112
|
{post.cover && (
|
|
115
113
|
<figure className="article-hero article-cover cover-card cover-img">
|
|
116
|
-
{
|
|
117
|
-
<img className="u-photo" src={post.cover} alt={`${post.title} 封面`} />
|
|
114
|
+
<CoverImage className="u-photo" src={post.cover} image={post.coverImage} alt={`${post.title} 封面`} sizes="(max-width: 920px) calc(100vw - 40px), 880px" priority />
|
|
118
115
|
</figure>
|
|
119
116
|
)}
|
|
120
117
|
</header>
|
|
@@ -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,7 +1,8 @@
|
|
|
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
|
+
import { archivePosts, archiveSeries } from "../lib/archive";
|
|
5
6
|
|
|
6
7
|
export const metadata = {
|
|
7
8
|
title: siteDocumentTitle,
|
|
@@ -9,14 +10,16 @@ export const metadata = {
|
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
export default function BlogIndex() {
|
|
13
|
+
const list = archivePosts();
|
|
14
|
+
const series = archiveSeries();
|
|
12
15
|
return (
|
|
13
16
|
<main className="sh-main" id="content">
|
|
14
17
|
<div className="sh-head">
|
|
15
18
|
<h1 id="sh-title">文章</h1>
|
|
16
19
|
<p className="sh-deck">阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</p>
|
|
17
20
|
</div>
|
|
18
|
-
<Suspense>
|
|
19
|
-
<BlogArchive />
|
|
21
|
+
<Suspense fallback={<BlogArchiveFallback posts={list} series={series} />}>
|
|
22
|
+
<BlogArchive posts={list} series={series} />
|
|
20
23
|
</Suspense>
|
|
21
24
|
</main>
|
|
22
25
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
|
+
import { CoverImage } from "../../../components/CoverImage";
|
|
2
3
|
import type { Metadata } from "next";
|
|
3
4
|
import { notFound } from "next/navigation";
|
|
4
5
|
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
|
|
@@ -17,6 +18,7 @@ import { MarkdownArticle } from "../../../components/MarkdownArticle";
|
|
|
17
18
|
import { TableOfContents } from "../../../components/TableOfContents";
|
|
18
19
|
import { Comments } from "../../../components/Comments";
|
|
19
20
|
import { Webmentions } from "../../../components/Webmentions";
|
|
21
|
+
import { ArticleTags } from "../../../components/ArticleTags";
|
|
20
22
|
import {
|
|
21
23
|
absoluteUrl,
|
|
22
24
|
getSiteUrl,
|
|
@@ -122,21 +124,14 @@ export default async function ColumnEntryPage({
|
|
|
122
124
|
{formatDate(entry.date)}
|
|
123
125
|
</time>
|
|
124
126
|
</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
127
|
{originalUrl(entry) ? (
|
|
129
128
|
<li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">原文 ↗</a></li>
|
|
130
129
|
) : null}
|
|
131
130
|
</ul>
|
|
131
|
+
<ArticleTags tags={entry.tags} />
|
|
132
132
|
{entry.cover && (
|
|
133
133
|
<figure className="article-hero article-cover cover-img">
|
|
134
|
-
{
|
|
135
|
-
<img
|
|
136
|
-
className="u-photo"
|
|
137
|
-
src={entry.cover}
|
|
138
|
-
alt={`${entry.title} 封面`}
|
|
139
|
-
/>
|
|
134
|
+
<CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={`${entry.title} 封面`} sizes="(max-width: 920px) calc(100vw - 40px), 880px" priority />
|
|
140
135
|
</figure>
|
|
141
136
|
)}
|
|
142
137
|
</header>
|
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;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { referenceColumns, referenceDomain } from "../../ui/garden/references.mjs";
|
|
2
|
+
|
|
1
3
|
type ReferenceItem = {
|
|
2
4
|
title: string;
|
|
3
5
|
url?: string;
|
|
6
|
+
domain?: string;
|
|
4
7
|
};
|
|
5
8
|
|
|
6
9
|
export function ArticleReferences({
|
|
@@ -9,39 +12,36 @@ export function ArticleReferences({
|
|
|
9
12
|
references?: ReferenceItem[];
|
|
10
13
|
}) {
|
|
11
14
|
if (!references?.length) return null;
|
|
15
|
+
const split = referenceColumns(references.length) === 2;
|
|
12
16
|
|
|
13
17
|
return (
|
|
14
|
-
<section
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
42
|
-
})}
|
|
43
|
-
</tbody>
|
|
44
|
-
</table>
|
|
18
|
+
<section
|
|
19
|
+
className={split ? "article-references is-split" : "article-references"}
|
|
20
|
+
aria-labelledby="article-references-heading"
|
|
21
|
+
>
|
|
22
|
+
<h2 id="article-references-heading">参考</h2>
|
|
23
|
+
<ol>
|
|
24
|
+
{references.map((item, index) => {
|
|
25
|
+
const domain = item.domain ?? referenceDomain(item.url);
|
|
26
|
+
return (
|
|
27
|
+
<li key={index}>
|
|
28
|
+
{item.url ? (
|
|
29
|
+
<a href={item.url} rel="noreferrer">
|
|
30
|
+
{item.title}
|
|
31
|
+
</a>
|
|
32
|
+
) : (
|
|
33
|
+
<span className="ref-title">{item.title}</span>
|
|
34
|
+
)}
|
|
35
|
+
{domain ? (
|
|
36
|
+
<>
|
|
37
|
+
{" "}
|
|
38
|
+
<span className="ref-domain">{domain}</span>
|
|
39
|
+
</>
|
|
40
|
+
) : null}
|
|
41
|
+
</li>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</ol>
|
|
45
45
|
</section>
|
|
46
46
|
);
|
|
47
47
|
}
|
|
@@ -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
|
+
}
|