hd-garden 0.9.4 → 0.10.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/app/about/page.tsx +1 -1
- package/app/blog/[...path]/page.tsx +9 -78
- package/app/blog/page.tsx +5 -4
- package/app/columns/[column]/[...path]/page.tsx +29 -91
- package/app/columns/page.tsx +6 -4
- package/app/components/PageIntro.tsx +9 -4
- package/app/components/PostReadingView.tsx +133 -0
- package/app/components/SeriesEdgeNav.tsx +119 -0
- package/app/components/SiteChrome.tsx +3 -17
- package/app/components/SiteHeader.tsx +3 -3
- package/app/garden-modules.d.ts +0 -6
- package/app/layout.tsx +1 -2
- package/app/lib/i18n.ts +8 -14
- package/app/page.tsx +3 -1
- package/app/sitemap.ts +0 -11
- package/app/styles/a2.css +204 -0
- package/app/styles/shell.css +0 -37
- package/app/thoughts/page/[n]/page.tsx +1 -1
- package/app/thoughts/page.tsx +1 -1
- package/docs/handbook/config.md +2 -59
- package/docs/handbook/en/config.md +2 -59
- package/package.json +1 -2
- package/scripts/build-content.mjs +1 -31
- package/scripts/handbook-facts.mjs +1 -2
- package/ui/garden/site-nav.mjs +3 -13
- package/vite.config.ts +3 -12
- package/app/bookshelf/[bookId]/page.tsx +0 -72
- package/app/bookshelf/load.mjs +0 -114
- package/app/bookshelf/load.ts +0 -16
- package/app/bookshelf/page.tsx +0 -52
- package/app/components/bookshelf/BookHub.tsx +0 -142
- package/app/components/bookshelf/Bookshelf.tsx +0 -141
- package/app/components/bookshelf/bookshelf.css +0 -1352
- package/app/components/bookshelf/catalog.mjs +0 -176
- package/app/components/bookshelf/catalog.ts +0 -27
- package/app/components/bookshelf/demo-data.ts +0 -93
- package/app/components/bookshelf/density.ts +0 -32
- package/app/components/bookshelf/engine.ts +0 -3057
- package/app/components/bookshelf/index.ts +0 -15
- package/app/components/bookshelf/model.ts +0 -16
- package/app/components/bookshelf/schema.mjs +0 -155
- package/app/components/bookshelf/schema.ts +0 -110
- package/app/components/bookshelf/types.ts +0 -94
- package/public/covers/accelerate.jpg +0 -0
- package/public/covers/clean-architecture.jpg +0 -0
- package/public/covers/clean-code.jpg +0 -0
- package/public/covers/continuous-delivery.jpg +0 -0
- package/public/covers/ddd.jpg +0 -0
- package/public/covers/ddia.jpg +0 -0
- package/public/covers/design-patterns.jpg +0 -0
- package/public/covers/eaa.jpg +0 -0
- package/public/covers/fundamentals-sa.jpg +0 -0
- package/public/covers/implementing-ddd.jpg +0 -0
- package/public/covers/lean-startup.jpg +0 -0
- package/public/covers/microservices.jpg +0 -0
- package/public/covers/mythical-man-month.jpg +0 -0
- package/public/covers/phoenix-project.jpg +0 -0
- package/public/covers/pragmatic-programmer.jpg +0 -0
- package/public/covers/refactoring.jpg +0 -0
- package/public/covers/release-it.jpg +0 -0
- package/public/covers/sre.jpg +0 -0
- package/public/covers/systems-performance.jpg +0 -0
- package/public/covers/tdd.jpg +0 -0
- package/public/covers/team-topologies.jpg +0 -0
- package/scripts/bookshelf-covers.mjs +0 -62
package/app/about/page.tsx
CHANGED
|
@@ -1,34 +1,18 @@
|
|
|
1
|
-
import Link from "next/link";
|
|
2
|
-
import { CoverImage } from "../../components/CoverImage";
|
|
3
1
|
import type { Metadata } from "next";
|
|
4
2
|
import { FEED_ALTERNATES } from "../../lib/page-alternates";
|
|
5
3
|
import { notFound } from "next/navigation";
|
|
6
4
|
import {
|
|
7
|
-
extractHeadings,
|
|
8
|
-
estimateWordCount,
|
|
9
5
|
findPost,
|
|
10
|
-
formatDate,
|
|
11
6
|
postHref,
|
|
12
7
|
posts,
|
|
13
8
|
} from "../../lib/content";
|
|
14
|
-
import {
|
|
15
|
-
import { ArticleReferences } from "../../components/ArticleReferences";
|
|
16
|
-
import { ArticleTitle, originalUrl } from "../../components/ArticleTitle";
|
|
17
|
-
import { MarkdownArticle } from "../../components/MarkdownArticle";
|
|
18
|
-
import { TableOfContents } from "../../components/TableOfContents";
|
|
19
|
-
import { Comments } from "../../components/Comments";
|
|
20
|
-
import { Webmentions } from "../../components/Webmentions";
|
|
21
|
-
import { ArticleTags } from "../../components/ArticleTags";
|
|
22
|
-
import { ArticlePager } from "../../components/ArticlePager";
|
|
9
|
+
import { PostReadingView } from "../../components/PostReadingView";
|
|
23
10
|
import { adjacentByDate } from "../../../ui/garden/adjacent.mjs";
|
|
24
11
|
import {
|
|
25
12
|
absoluteUrl,
|
|
26
13
|
getSiteUrl,
|
|
27
|
-
webmentionApiEndpoint,
|
|
28
14
|
} from "../../lib/site";
|
|
29
|
-
import { githubUrl, siteConfig } from "../../site.config";
|
|
30
15
|
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
|
|
31
|
-
import { siteLang, ui } from "../../lib/i18n";
|
|
32
16
|
|
|
33
17
|
export function generateStaticParams() {
|
|
34
18
|
return staticParams(posts.map((post) => ({ path: post.path.split("/") })), { path: [STATIC_PLACEHOLDER] });
|
|
@@ -80,72 +64,19 @@ export default async function PostPage({
|
|
|
80
64
|
if (isStaticPlaceholder(path)) return null;
|
|
81
65
|
notFound();
|
|
82
66
|
}
|
|
83
|
-
const headings = extractHeadings(post.content, { numbering: post.numbering });
|
|
84
67
|
const baseUrl = await getSiteUrl();
|
|
85
68
|
const pathname = postHref(post);
|
|
86
69
|
const canonicalUrl = absoluteUrl(pathname, baseUrl);
|
|
87
|
-
const wordCount = estimateWordCount(post.content);
|
|
88
70
|
const { prev, next } = adjacentByDate(posts, (item: { path: string }) => item.path === post.path);
|
|
89
|
-
const t = ui();
|
|
90
71
|
|
|
91
72
|
return (
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
rel="author"
|
|
101
|
-
>
|
|
102
|
-
{siteConfig.author.name}
|
|
103
|
-
</a>
|
|
104
|
-
<header className="article-header article-summary-row hero">
|
|
105
|
-
<ArticleTitle title={post.title} />
|
|
106
|
-
<ul className="meta article-meta">
|
|
107
|
-
<li>{siteConfig.author.name}</li>
|
|
108
|
-
<li>
|
|
109
|
-
<time className="dt-published" dateTime={post.date}>
|
|
110
|
-
{formatDate(post.date)}
|
|
111
|
-
</time>
|
|
112
|
-
</li>
|
|
113
|
-
{post.updated ? <li>{siteLang === "en" ? t.updatedOn(formatDate(post.updated)) : <>更新于 {formatDate(post.updated)}</>}</li> : null}
|
|
114
|
-
<li>{t.wordCount(wordCount)}</li>
|
|
115
|
-
{originalUrl(post) ? (
|
|
116
|
-
<li>
|
|
117
|
-
<a className="orig ext" href={originalUrl(post)} target="_blank" rel="noopener noreferrer">{t.originalLink}</a>
|
|
118
|
-
</li>
|
|
119
|
-
) : null}
|
|
120
|
-
</ul>
|
|
121
|
-
<ArticleTags tags={post.tags} />
|
|
122
|
-
{post.cover && (
|
|
123
|
-
<figure className="article-hero article-cover cover-card cover-img">
|
|
124
|
-
<CoverImage className="u-photo" src={post.cover} image={post.coverImage} alt={t.coverLabel(post.title)} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
|
|
125
|
-
</figure>
|
|
126
|
-
)}
|
|
127
|
-
</header>
|
|
128
|
-
<div className="article-reading-layout">
|
|
129
|
-
<div className="article-reading-column">
|
|
130
|
-
<ArticleMeasure />
|
|
131
|
-
<div className="e-content">
|
|
132
|
-
{post.description ? <p className="lead quote-plain p-summary">{post.description}</p> : null}
|
|
133
|
-
<MarkdownArticle content={post.content} numbering={post.numbering} />
|
|
134
|
-
</div>
|
|
135
|
-
<ArticleReferences references={post.references} />
|
|
136
|
-
<Webmentions
|
|
137
|
-
target={canonicalUrl}
|
|
138
|
-
endpoint={webmentionApiEndpoint()}
|
|
139
|
-
/>
|
|
140
|
-
<ArticlePager
|
|
141
|
-
prev={prev ? { href: postHref(prev), title: prev.title } : null}
|
|
142
|
-
next={next ? { href: postHref(next), title: next.title } : null}
|
|
143
|
-
/>
|
|
144
|
-
<Comments slug={post.path} />
|
|
145
|
-
</div>
|
|
146
|
-
<TableOfContents headings={headings} />
|
|
147
|
-
</div>
|
|
148
|
-
</article>
|
|
149
|
-
</main>
|
|
73
|
+
<PostReadingView
|
|
74
|
+
post={post}
|
|
75
|
+
pathname={pathname}
|
|
76
|
+
canonicalUrl={canonicalUrl}
|
|
77
|
+
commentsSlug={post.path}
|
|
78
|
+
prev={prev ? { href: postHref(prev), title: prev.title } : null}
|
|
79
|
+
next={next ? { href: postHref(next), title: next.title } : null}
|
|
80
|
+
/>
|
|
150
81
|
);
|
|
151
82
|
}
|
package/app/blog/page.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Suspense } from "react";
|
|
2
2
|
import { siteConfig, siteDocumentTitle } from "../site.config";
|
|
3
3
|
import { BlogArchive, BlogArchiveFallback } from "../components/BlogArchive";
|
|
4
|
+
import { PageIntro } from "../components/PageIntro";
|
|
4
5
|
import { posts } from "../lib/content";
|
|
5
6
|
import { archivePosts, archiveSeries } from "../lib/archive";
|
|
6
7
|
import { pageAlternates } from "../lib/page-alternates";
|
|
@@ -16,12 +17,12 @@ export default function BlogIndex() {
|
|
|
16
17
|
const t = ui();
|
|
17
18
|
const list = archivePosts();
|
|
18
19
|
const series = archiveSeries();
|
|
20
|
+
const deck =
|
|
21
|
+
siteConfig.pages.blog.subtitle?.trim() ||
|
|
22
|
+
(siteLang === "en" ? t.blogDeck(posts.length) : `阶段性的完整沉淀,关于工程、架构与技术思考。共 ${posts.length} 篇,按年份归档。`);
|
|
19
23
|
return (
|
|
20
24
|
<main className="sh-main" id="content">
|
|
21
|
-
<
|
|
22
|
-
<h1 id="sh-title">{t.blogHeading}</h1>
|
|
23
|
-
<p className="sh-deck">{siteLang === "en" ? t.blogDeck(posts.length) : <>阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</>}</p>
|
|
24
|
-
</div>
|
|
25
|
+
<PageIntro eyebrow={t.blogEyebrow} title={t.blogHeading} subtitle={deck} />
|
|
25
26
|
<Suspense fallback={<BlogArchiveFallback posts={list} series={series} />}>
|
|
26
27
|
<BlogArchive posts={list} series={series} />
|
|
27
28
|
</Suspense>
|
|
@@ -1,35 +1,21 @@
|
|
|
1
|
-
import Link from "next/link";
|
|
2
|
-
import { ArticlePager } from "../../../components/ArticlePager";
|
|
3
|
-
import { CoverImage } from "../../../components/CoverImage";
|
|
4
1
|
import type { Metadata } from "next";
|
|
5
2
|
import { FEED_ALTERNATES } from "../../../lib/page-alternates";
|
|
6
3
|
import { notFound } from "next/navigation";
|
|
7
|
-
import { ArrowLeft } from "@phosphor-icons/react/ssr";
|
|
8
4
|
import {
|
|
9
5
|
columns,
|
|
10
|
-
extractHeadings,
|
|
11
6
|
findColumnEntry,
|
|
12
|
-
formatDate,
|
|
13
7
|
columnHref,
|
|
14
8
|
columnEntryPath,
|
|
15
9
|
} from "../../../lib/content";
|
|
16
|
-
import {
|
|
17
|
-
import { ArticleReferences } from "../../../components/ArticleReferences";
|
|
18
|
-
import { ArticleTitle, originalUrl } from "../../../components/ArticleTitle";
|
|
19
|
-
import { MarkdownArticle } from "../../../components/MarkdownArticle";
|
|
20
|
-
import { TableOfContents } from "../../../components/TableOfContents";
|
|
21
|
-
import { Comments } from "../../../components/Comments";
|
|
22
|
-
import { Webmentions } from "../../../components/Webmentions";
|
|
23
|
-
import { ArticleTags } from "../../../components/ArticleTags";
|
|
10
|
+
import { PostReadingView } from "../../../components/PostReadingView";
|
|
24
11
|
import {
|
|
25
12
|
absoluteUrl,
|
|
26
13
|
getSiteUrl,
|
|
27
|
-
webmentionApiEndpoint,
|
|
28
14
|
} from "../../../lib/site";
|
|
29
|
-
import { githubUrl, siteConfig } from "../../../site.config";
|
|
30
|
-
import { siteHref } from "../../../lib/site-href";
|
|
31
15
|
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
|
|
32
|
-
import { columnStatusLabel,
|
|
16
|
+
import { columnStatusLabel, ui } from "../../../lib/i18n";
|
|
17
|
+
|
|
18
|
+
const stripColumnIndex = (title: string) => title.replace(/^\d+\s*·\s*/u, "");
|
|
33
19
|
|
|
34
20
|
export function generateStaticParams() {
|
|
35
21
|
return staticParams(
|
|
@@ -51,14 +37,15 @@ export async function generateMetadata({
|
|
|
51
37
|
if (!entry) return {};
|
|
52
38
|
const baseUrl = await getSiteUrl();
|
|
53
39
|
const pathname = columnHref(entry);
|
|
40
|
+
const title = stripColumnIndex(entry.title);
|
|
54
41
|
return {
|
|
55
|
-
title
|
|
42
|
+
title,
|
|
56
43
|
description: entry.description,
|
|
57
44
|
alternates: { canonical: pathname, types: FEED_ALTERNATES },
|
|
58
45
|
openGraph: {
|
|
59
46
|
type: "article",
|
|
60
47
|
url: absoluteUrl(pathname, baseUrl),
|
|
61
|
-
title
|
|
48
|
+
title,
|
|
62
49
|
description: entry.description,
|
|
63
50
|
publishedTime: new Date(entry.date).toISOString(),
|
|
64
51
|
modifiedTime: entry.updated
|
|
@@ -69,7 +56,7 @@ export async function generateMetadata({
|
|
|
69
56
|
},
|
|
70
57
|
twitter: {
|
|
71
58
|
card: "summary_large_image",
|
|
72
|
-
title
|
|
59
|
+
title,
|
|
73
60
|
description: entry.description,
|
|
74
61
|
images: entry.cover ? [entry.cover] : ["/og.png"],
|
|
75
62
|
},
|
|
@@ -92,81 +79,32 @@ export default async function ColumnEntryPage({
|
|
|
92
79
|
const currentIndex = series.findIndex((item) => item.path === entry.path);
|
|
93
80
|
const prev = series[currentIndex - 1] ?? null;
|
|
94
81
|
const next = series[currentIndex + 1] ?? null;
|
|
95
|
-
const headings = extractHeadings(entry.content, { numbering: entry.numbering });
|
|
96
82
|
const baseUrl = await getSiteUrl();
|
|
97
83
|
const pathname = columnHref(entry);
|
|
98
84
|
const canonicalUrl = absoluteUrl(pathname, baseUrl);
|
|
99
85
|
const t = ui();
|
|
86
|
+
const displayTitle = stripColumnIndex(entry.title);
|
|
100
87
|
|
|
101
88
|
return (
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
{siteLang === "en" ? t.permalinkLabel(entry.title) : <>{entry.title} 永久链接</>}
|
|
123
|
-
</Link>
|
|
124
|
-
<a
|
|
125
|
-
className="p-author h-card sr-only"
|
|
126
|
-
href={githubUrl}
|
|
127
|
-
rel="author"
|
|
128
|
-
>
|
|
129
|
-
{siteConfig.author.name}
|
|
130
|
-
</a>
|
|
131
|
-
<header className="article-header hero">
|
|
132
|
-
<ArticleTitle title={entry.title.replace(/^\d+\s*·\s*/u, "")} />
|
|
133
|
-
<ul className="meta article-meta">
|
|
134
|
-
<li>{siteConfig.author.name}</li>
|
|
135
|
-
<li>
|
|
136
|
-
<time className="dt-published" dateTime={entry.date}>
|
|
137
|
-
{formatDate(entry.date)}
|
|
138
|
-
</time>
|
|
139
|
-
</li>
|
|
140
|
-
{originalUrl(entry) ? (
|
|
141
|
-
<li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">{t.originalLink}</a></li>
|
|
142
|
-
) : null}
|
|
143
|
-
</ul>
|
|
144
|
-
<ArticleTags tags={entry.tags} />
|
|
145
|
-
{entry.cover && (
|
|
146
|
-
<figure className="article-hero article-cover cover-img">
|
|
147
|
-
<CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={siteLang === "en" ? t.coverLabel(entry.title) : `${entry.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
|
|
148
|
-
</figure>
|
|
149
|
-
)}
|
|
150
|
-
</header>
|
|
151
|
-
<ArticleMeasure />
|
|
152
|
-
<div className="e-content">
|
|
153
|
-
{entry.description ? <p className="lead p-summary">{entry.description}</p> : null}
|
|
154
|
-
<MarkdownArticle content={entry.content} numbering={entry.numbering} />
|
|
155
|
-
</div>
|
|
156
|
-
<ArticleReferences references={entry.references} />
|
|
157
|
-
<Webmentions
|
|
158
|
-
target={canonicalUrl}
|
|
159
|
-
endpoint={webmentionApiEndpoint()}
|
|
160
|
-
/>
|
|
161
|
-
<ArticlePager
|
|
162
|
-
label={ui().columnPagerLabel}
|
|
163
|
-
prev={prev ? { href: columnHref(prev), title: prev.title } : null}
|
|
164
|
-
next={next ? { href: columnHref(next), title: next.title } : null}
|
|
165
|
-
/>
|
|
166
|
-
<Comments slug={`${column}:${entry.path}`} />
|
|
167
|
-
</article>
|
|
168
|
-
|
|
169
|
-
<TableOfContents headings={headings} />
|
|
170
|
-
</main>
|
|
89
|
+
<PostReadingView
|
|
90
|
+
post={entry}
|
|
91
|
+
pathname={pathname}
|
|
92
|
+
canonicalUrl={canonicalUrl}
|
|
93
|
+
title={displayTitle}
|
|
94
|
+
commentsSlug={`${column}:${entry.path}`}
|
|
95
|
+
pagerLabel={t.columnPagerLabel}
|
|
96
|
+
prev={prev ? { href: columnHref(prev), title: stripColumnIndex(prev.title) } : null}
|
|
97
|
+
next={next ? { href: columnHref(next), title: stripColumnIndex(next.title) } : null}
|
|
98
|
+
series={{
|
|
99
|
+
columnTitle: entry.columnTitle ?? column,
|
|
100
|
+
statusLabel: t.columnSideCount(series.length, columnStatusLabel(entry.columnStatus)),
|
|
101
|
+
items: listed.map((item) => ({
|
|
102
|
+
path: item.path,
|
|
103
|
+
href: columnHref(item),
|
|
104
|
+
title: stripColumnIndex(item.title),
|
|
105
|
+
current: item.path === entry.path,
|
|
106
|
+
})),
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
171
109
|
);
|
|
172
110
|
}
|
package/app/columns/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Link from "next/link";
|
|
3
3
|
import { columnHref, columns } from "../lib/content";
|
|
4
4
|
import { ContentEmptyState } from "../components/ContentEmptyState";
|
|
5
|
+
import { PageIntro } from "../components/PageIntro";
|
|
5
6
|
import { siteConfig, siteDocumentTitle } from "../site.config";
|
|
6
7
|
import { pageAlternates } from "../lib/page-alternates";
|
|
7
8
|
import { columnStatusLabel, ui } from "../lib/i18n";
|
|
@@ -40,10 +41,11 @@ export default function ColumnsPage() {
|
|
|
40
41
|
|
|
41
42
|
return (
|
|
42
43
|
<main className="sh-main" id="content">
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
{siteConfig.pages.columns.
|
|
46
|
-
|
|
44
|
+
<PageIntro
|
|
45
|
+
eyebrow={t.columnsEyebrow}
|
|
46
|
+
title={siteConfig.pages.columns.title || t.homeColumns}
|
|
47
|
+
subtitle={siteConfig.pages.columns.subtitle}
|
|
48
|
+
/>
|
|
47
49
|
{grouped.length ? grouped.map(({ slug, first, entries }) => (
|
|
48
50
|
<section className={first.columnCover ? "sh-series" : "sh-series no-cover"} aria-labelledby={slug} key={slug}>
|
|
49
51
|
{first.columnCover ? (
|
|
@@ -7,15 +7,20 @@ type PageIntroProps = {
|
|
|
7
7
|
children?: ReactNode;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Shared heading for top-level list pages (blog / thoughts / columns).
|
|
12
|
+
* Same eyebrow + h1 + deck typography and padding so tab switches do not jump.
|
|
13
|
+
* Empty subtitle reserves no space.
|
|
14
|
+
*/
|
|
11
15
|
export function PageIntro({
|
|
12
16
|
eyebrow,
|
|
13
17
|
title,
|
|
14
18
|
subtitle,
|
|
15
19
|
children,
|
|
16
20
|
}: PageIntroProps) {
|
|
21
|
+
const deck = subtitle?.trim();
|
|
17
22
|
return (
|
|
18
|
-
<header className="page-intro">
|
|
23
|
+
<header className="page-intro sh-head">
|
|
19
24
|
{eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
|
|
20
25
|
<h1>
|
|
21
26
|
{title.split("\n").map((line, index) => (
|
|
@@ -25,8 +30,8 @@ export function PageIntro({
|
|
|
25
30
|
</span>
|
|
26
31
|
))}
|
|
27
32
|
</h1>
|
|
28
|
-
{
|
|
29
|
-
{children
|
|
33
|
+
{deck ? <p className="sh-deck">{deck}</p> : null}
|
|
34
|
+
{children ? <div className="page-intro-actions">{children}</div> : null}
|
|
30
35
|
</header>
|
|
31
36
|
);
|
|
32
37
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { CoverImage } from "./CoverImage";
|
|
3
|
+
import { ArticleMeasure } from "./ArticleMeasure";
|
|
4
|
+
import { ArticleReferences } from "./ArticleReferences";
|
|
5
|
+
import { ArticleTitle, originalUrl } from "./ArticleTitle";
|
|
6
|
+
import { MarkdownArticle } from "./MarkdownArticle";
|
|
7
|
+
import { TableOfContents } from "./TableOfContents";
|
|
8
|
+
import { Comments } from "./Comments";
|
|
9
|
+
import { Webmentions } from "./Webmentions";
|
|
10
|
+
import { ArticleTags } from "./ArticleTags";
|
|
11
|
+
import { ArticlePager } from "./ArticlePager";
|
|
12
|
+
import { SeriesEdgeNav, type SeriesEdgeItem } from "./SeriesEdgeNav";
|
|
13
|
+
import { formatDate, estimateWordCount, extractHeadings } from "../lib/content";
|
|
14
|
+
import { githubUrl, siteConfig } from "../site.config";
|
|
15
|
+
import { siteLang, ui } from "../lib/i18n";
|
|
16
|
+
import { webmentionApiEndpoint } from "../lib/site";
|
|
17
|
+
import type { ContentEntry } from "../lib/content";
|
|
18
|
+
|
|
19
|
+
export type PostReadingPagerLink = { href: string; title: string } | null;
|
|
20
|
+
|
|
21
|
+
export type PostReadingSeries = {
|
|
22
|
+
columnTitle: string;
|
|
23
|
+
statusLabel?: string;
|
|
24
|
+
items: SeriesEdgeItem[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shared article body for /blog/... and /columns/... .
|
|
29
|
+
* Column routes only differ by mounting SeriesEdgeNav on the left.
|
|
30
|
+
*/
|
|
31
|
+
export function PostReadingView({
|
|
32
|
+
post,
|
|
33
|
+
pathname,
|
|
34
|
+
canonicalUrl,
|
|
35
|
+
title,
|
|
36
|
+
commentsSlug,
|
|
37
|
+
prev,
|
|
38
|
+
next,
|
|
39
|
+
pagerLabel,
|
|
40
|
+
series,
|
|
41
|
+
}: {
|
|
42
|
+
post: ContentEntry;
|
|
43
|
+
pathname: string;
|
|
44
|
+
canonicalUrl: string;
|
|
45
|
+
/** Display title; defaults to post.title */
|
|
46
|
+
title?: string;
|
|
47
|
+
commentsSlug: string;
|
|
48
|
+
prev?: PostReadingPagerLink;
|
|
49
|
+
next?: PostReadingPagerLink;
|
|
50
|
+
pagerLabel?: string;
|
|
51
|
+
series?: PostReadingSeries;
|
|
52
|
+
}) {
|
|
53
|
+
const displayTitle = title ?? post.title;
|
|
54
|
+
const numbering = (post as ContentEntry & { numbering?: boolean }).numbering;
|
|
55
|
+
const headings = extractHeadings(post.content, { numbering });
|
|
56
|
+
const wordCount = estimateWordCount(post.content);
|
|
57
|
+
const t = ui();
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<main className="reading-page post-reading-page">
|
|
61
|
+
{series ? (
|
|
62
|
+
<SeriesEdgeNav
|
|
63
|
+
columnTitle={series.columnTitle}
|
|
64
|
+
statusLabel={series.statusLabel}
|
|
65
|
+
items={series.items}
|
|
66
|
+
/>
|
|
67
|
+
) : null}
|
|
68
|
+
<article className="post-article h-entry">
|
|
69
|
+
<Link className="u-url sr-only" href={pathname}>
|
|
70
|
+
{siteLang === "en" ? t.permalinkLabel(displayTitle) : <>{displayTitle} 永久链接</>}
|
|
71
|
+
</Link>
|
|
72
|
+
<a className="p-author h-card sr-only" href={githubUrl} rel="author">
|
|
73
|
+
{siteConfig.author.name}
|
|
74
|
+
</a>
|
|
75
|
+
<header className="article-header article-summary-row hero">
|
|
76
|
+
<ArticleTitle title={displayTitle} />
|
|
77
|
+
<ul className="meta article-meta">
|
|
78
|
+
<li>{siteConfig.author.name}</li>
|
|
79
|
+
<li>
|
|
80
|
+
<time className="dt-published" dateTime={post.date}>
|
|
81
|
+
{formatDate(post.date)}
|
|
82
|
+
</time>
|
|
83
|
+
</li>
|
|
84
|
+
{post.updated ? (
|
|
85
|
+
<li>
|
|
86
|
+
{siteLang === "en" ? t.updatedOn(formatDate(post.updated)) : <>更新于 {formatDate(post.updated)}</>}
|
|
87
|
+
</li>
|
|
88
|
+
) : null}
|
|
89
|
+
<li>{t.wordCount(wordCount)}</li>
|
|
90
|
+
{originalUrl(post) ? (
|
|
91
|
+
<li>
|
|
92
|
+
<a className="orig ext" href={originalUrl(post)} target="_blank" rel="noopener noreferrer">
|
|
93
|
+
{t.originalLink}
|
|
94
|
+
</a>
|
|
95
|
+
</li>
|
|
96
|
+
) : null}
|
|
97
|
+
</ul>
|
|
98
|
+
<ArticleTags tags={post.tags} />
|
|
99
|
+
{post.cover ? (
|
|
100
|
+
<figure className="article-hero article-cover cover-card cover-img">
|
|
101
|
+
<CoverImage
|
|
102
|
+
className="u-photo"
|
|
103
|
+
src={post.cover}
|
|
104
|
+
image={post.coverImage}
|
|
105
|
+
alt={t.coverLabel(displayTitle)}
|
|
106
|
+
sizes="(max-width: 852px) calc(100vw - 40px), 812px"
|
|
107
|
+
priority
|
|
108
|
+
/>
|
|
109
|
+
</figure>
|
|
110
|
+
) : null}
|
|
111
|
+
</header>
|
|
112
|
+
<div className="article-reading-layout">
|
|
113
|
+
<div className="article-reading-column">
|
|
114
|
+
<ArticleMeasure />
|
|
115
|
+
<div className="e-content">
|
|
116
|
+
{post.description ? <p className="lead quote-plain p-summary">{post.description}</p> : null}
|
|
117
|
+
<MarkdownArticle content={post.content} numbering={numbering} />
|
|
118
|
+
</div>
|
|
119
|
+
<ArticleReferences references={post.references} />
|
|
120
|
+
<Webmentions target={canonicalUrl} endpoint={webmentionApiEndpoint()} />
|
|
121
|
+
<ArticlePager
|
|
122
|
+
label={pagerLabel}
|
|
123
|
+
prev={prev ?? null}
|
|
124
|
+
next={next ?? null}
|
|
125
|
+
/>
|
|
126
|
+
<Comments slug={commentsSlug} />
|
|
127
|
+
</div>
|
|
128
|
+
<TableOfContents headings={headings} />
|
|
129
|
+
</div>
|
|
130
|
+
</article>
|
|
131
|
+
</main>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
export type SeriesEdgeItem = {
|
|
14
|
+
path: string;
|
|
15
|
+
href: string;
|
|
16
|
+
title: string;
|
|
17
|
+
current?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Left-edge Raschka ticks for column articles.
|
|
22
|
+
* Flyout is the article list only (no column chrome).
|
|
23
|
+
* Opens on hover or click; stays open until click-outside or Escape.
|
|
24
|
+
*/
|
|
25
|
+
export function SeriesEdgeNav({
|
|
26
|
+
columnTitle,
|
|
27
|
+
items,
|
|
28
|
+
}: {
|
|
29
|
+
columnTitle: string;
|
|
30
|
+
items: SeriesEdgeItem[];
|
|
31
|
+
/** @deprecated kept optional so callers can pass it; not shown in the flyout */
|
|
32
|
+
statusLabel?: string;
|
|
33
|
+
lang?: string;
|
|
34
|
+
}) {
|
|
35
|
+
const panelId = useId();
|
|
36
|
+
const rootRef = useRef<HTMLElement>(null);
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const currentIndex = Math.max(0, items.findIndex((item) => item.current));
|
|
39
|
+
|
|
40
|
+
const close = useCallback(() => setOpen(false), []);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return;
|
|
44
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
45
|
+
const root = rootRef.current;
|
|
46
|
+
if (!root) return;
|
|
47
|
+
if (event.target instanceof Node && root.contains(event.target)) return;
|
|
48
|
+
close();
|
|
49
|
+
};
|
|
50
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
51
|
+
if (event.key === "Escape") close();
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
54
|
+
document.addEventListener("keydown", onKeyDown);
|
|
55
|
+
return () => {
|
|
56
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
57
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
58
|
+
};
|
|
59
|
+
}, [close, open]);
|
|
60
|
+
|
|
61
|
+
const onTicksKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
62
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
setOpen((value) => !value);
|
|
65
|
+
} else if (event.key === "Escape") {
|
|
66
|
+
close();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<nav
|
|
72
|
+
ref={rootRef}
|
|
73
|
+
className="series-edge"
|
|
74
|
+
aria-label={columnTitle}
|
|
75
|
+
data-open={open ? "true" : "false"}
|
|
76
|
+
>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className="series-edge-ticks"
|
|
80
|
+
aria-controls={panelId}
|
|
81
|
+
aria-expanded={open}
|
|
82
|
+
aria-label={columnTitle}
|
|
83
|
+
onPointerEnter={() => setOpen(true)}
|
|
84
|
+
onFocus={() => setOpen(true)}
|
|
85
|
+
onClick={() => setOpen(true)}
|
|
86
|
+
onKeyDown={onTicksKeyDown}
|
|
87
|
+
>
|
|
88
|
+
{items.map((item, index) => (
|
|
89
|
+
<i
|
|
90
|
+
key={item.path}
|
|
91
|
+
className={index === currentIndex ? "hot" : undefined}
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</button>
|
|
96
|
+
{/* Hit bridge so the pointer can cross ticks → panel without a dead zone. */}
|
|
97
|
+
<div
|
|
98
|
+
className="series-edge-bridge"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
onPointerEnter={() => setOpen(true)}
|
|
101
|
+
/>
|
|
102
|
+
<div
|
|
103
|
+
className="series-edge-fly"
|
|
104
|
+
id={panelId}
|
|
105
|
+
onPointerEnter={() => setOpen(true)}
|
|
106
|
+
>
|
|
107
|
+
<ol className="series-edge-list">
|
|
108
|
+
{items.map((item) => (
|
|
109
|
+
<li key={item.path} className={item.current ? "cur" : undefined}>
|
|
110
|
+
<Link href={item.href} aria-current={item.current ? "page" : undefined}>
|
|
111
|
+
{item.title}
|
|
112
|
+
</Link>
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ol>
|
|
116
|
+
</div>
|
|
117
|
+
</nav>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -5,14 +5,7 @@ import { inAppPath, isHandbookPath, routeLang, ui } from "../lib/i18n";
|
|
|
5
5
|
import { SiteFooter } from "./SiteFooter";
|
|
6
6
|
import { SiteHeader } from "./SiteHeader";
|
|
7
7
|
|
|
8
|
-
export function SiteChrome({
|
|
9
|
-
children,
|
|
10
|
-
bookshelfSite = false,
|
|
11
|
-
}: {
|
|
12
|
-
children: React.ReactNode;
|
|
13
|
-
/** True when the site has content/bookshelf.yaml. The package demo stays chrome-less. */
|
|
14
|
-
bookshelfSite?: boolean;
|
|
15
|
-
}) {
|
|
8
|
+
export function SiteChrome({ children }: { children: React.ReactNode }) {
|
|
16
9
|
const pathname = usePathname();
|
|
17
10
|
const lang = routeLang(pathname);
|
|
18
11
|
// The skip link follows the route language too (/garden/en/ is English).
|
|
@@ -21,19 +14,12 @@ export function SiteChrome({
|
|
|
21
14
|
{ui(lang).skipToContent}
|
|
22
15
|
</a>
|
|
23
16
|
);
|
|
24
|
-
|
|
25
|
-
// A site shelf is a normal page, so it keeps the header and footer.
|
|
26
|
-
// Static export serves /bookshelf/ ; an exact "/bookshelf" check would miss it.
|
|
27
|
-
const path = inAppPath(pathname);
|
|
28
|
-
const shelfDemo = !bookshelfSite && (path === "/bookshelf" || path.startsWith("/bookshelf/"));
|
|
29
|
-
if (path.startsWith("/admin") || shelfDemo) {
|
|
30
|
-
return <>{skip}{children}</>;
|
|
31
|
-
}
|
|
17
|
+
if (inAppPath(pathname).startsWith("/admin")) return <>{skip}{children}</>;
|
|
32
18
|
|
|
33
19
|
return (
|
|
34
20
|
<>
|
|
35
21
|
{skip}
|
|
36
|
-
<SiteHeader
|
|
22
|
+
<SiteHeader />
|
|
37
23
|
<div id="main-content">{children}</div>
|
|
38
24
|
<SiteFooter docs={isHandbookPath(pathname)} lang={lang} />
|
|
39
25
|
</>
|