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.
Files changed (65) hide show
  1. package/app/about/page.tsx +1 -1
  2. package/app/blog/[...path]/page.tsx +9 -78
  3. package/app/blog/page.tsx +5 -4
  4. package/app/columns/[column]/[...path]/page.tsx +29 -91
  5. package/app/columns/page.tsx +6 -4
  6. package/app/components/PageIntro.tsx +9 -4
  7. package/app/components/PostReadingView.tsx +133 -0
  8. package/app/components/SeriesEdgeNav.tsx +119 -0
  9. package/app/components/SiteChrome.tsx +3 -17
  10. package/app/components/SiteHeader.tsx +3 -3
  11. package/app/garden-modules.d.ts +0 -6
  12. package/app/layout.tsx +1 -2
  13. package/app/lib/i18n.ts +8 -14
  14. package/app/page.tsx +3 -1
  15. package/app/sitemap.ts +0 -11
  16. package/app/styles/a2.css +204 -0
  17. package/app/styles/shell.css +0 -37
  18. package/app/thoughts/page/[n]/page.tsx +1 -1
  19. package/app/thoughts/page.tsx +1 -1
  20. package/docs/handbook/config.md +2 -59
  21. package/docs/handbook/en/config.md +2 -59
  22. package/package.json +1 -2
  23. package/scripts/build-content.mjs +1 -31
  24. package/scripts/handbook-facts.mjs +1 -2
  25. package/ui/garden/site-nav.mjs +3 -13
  26. package/vite.config.ts +3 -12
  27. package/app/bookshelf/[bookId]/page.tsx +0 -72
  28. package/app/bookshelf/load.mjs +0 -114
  29. package/app/bookshelf/load.ts +0 -16
  30. package/app/bookshelf/page.tsx +0 -52
  31. package/app/components/bookshelf/BookHub.tsx +0 -142
  32. package/app/components/bookshelf/Bookshelf.tsx +0 -141
  33. package/app/components/bookshelf/bookshelf.css +0 -1352
  34. package/app/components/bookshelf/catalog.mjs +0 -176
  35. package/app/components/bookshelf/catalog.ts +0 -27
  36. package/app/components/bookshelf/demo-data.ts +0 -93
  37. package/app/components/bookshelf/density.ts +0 -32
  38. package/app/components/bookshelf/engine.ts +0 -3057
  39. package/app/components/bookshelf/index.ts +0 -15
  40. package/app/components/bookshelf/model.ts +0 -16
  41. package/app/components/bookshelf/schema.mjs +0 -155
  42. package/app/components/bookshelf/schema.ts +0 -110
  43. package/app/components/bookshelf/types.ts +0 -94
  44. package/public/covers/accelerate.jpg +0 -0
  45. package/public/covers/clean-architecture.jpg +0 -0
  46. package/public/covers/clean-code.jpg +0 -0
  47. package/public/covers/continuous-delivery.jpg +0 -0
  48. package/public/covers/ddd.jpg +0 -0
  49. package/public/covers/ddia.jpg +0 -0
  50. package/public/covers/design-patterns.jpg +0 -0
  51. package/public/covers/eaa.jpg +0 -0
  52. package/public/covers/fundamentals-sa.jpg +0 -0
  53. package/public/covers/implementing-ddd.jpg +0 -0
  54. package/public/covers/lean-startup.jpg +0 -0
  55. package/public/covers/microservices.jpg +0 -0
  56. package/public/covers/mythical-man-month.jpg +0 -0
  57. package/public/covers/phoenix-project.jpg +0 -0
  58. package/public/covers/pragmatic-programmer.jpg +0 -0
  59. package/public/covers/refactoring.jpg +0 -0
  60. package/public/covers/release-it.jpg +0 -0
  61. package/public/covers/sre.jpg +0 -0
  62. package/public/covers/systems-performance.jpg +0 -0
  63. package/public/covers/tdd.jpg +0 -0
  64. package/public/covers/team-topologies.jpg +0 -0
  65. package/scripts/bookshelf-covers.mjs +0 -62
@@ -23,7 +23,7 @@ export const metadata = {
23
23
 
24
24
  export default function AboutPage() {
25
25
  return (
26
- <main className="page-shell about-page">
26
+ <main className="sh-main about-page" id="content">
27
27
  <PageIntro
28
28
  eyebrow={t.aboutEyebrow}
29
29
  title={about.title}
@@ -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 { ArticleMeasure } from "../../components/ArticleMeasure";
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
- <main className="reading-page post-reading-page">
93
- <article className="post-article h-entry">
94
- <Link className="u-url sr-only" href={pathname}>
95
- {siteLang === "en" ? t.permalinkLabel(post.title) : <>{post.title} 永久链接</>}
96
- </Link>
97
- <a
98
- className="p-author h-card sr-only"
99
- href={githubUrl}
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
- <div className="sh-head">
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 { ArticleMeasure } from "../../../components/ArticleMeasure";
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, siteLang, ui } from "../../../lib/i18n";
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: entry.title.replace(/^\d+\s*·\s*/u, ""),
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: entry.title.replace(/^\d+\s*·\s*/u, ""),
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: entry.title.replace(/^\d+\s*·\s*/u, ""),
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
- <main className="column-reading-page">
103
- <aside className="series-nav">
104
- <Link href={siteHref("/columns")} className="back-link">
105
- <ArrowLeft size={15} />
106
- {t.allColumnsBack}
107
- </Link>
108
- <p>{t.seriesEyebrow}</p>
109
- <h2>{entry.columnTitle}</h2>
110
- <ol>
111
- {listed.map((item) => (
112
- <li key={item.path} className={item.path === entry.path ? "active" : ""}>
113
- <Link href={columnHref(item)}>{item.title}</Link>
114
- </li>
115
- ))}
116
- </ol>
117
- <small>{ui().columnSideCount(series.length, columnStatusLabel(entry.columnStatus))}</small>
118
- </aside>
119
-
120
- <article className="article-column series-article h-entry">
121
- <Link className="u-url sr-only" href={pathname}>
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
  }
@@ -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
- <div className="sh-head">
44
- <h1>{siteConfig.pages.columns.title || t.homeColumns}</h1>
45
- {siteConfig.pages.columns.subtitle ? <p className="sh-deck">{siteConfig.pages.columns.subtitle}</p> : null}
46
- </div>
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
- /** Shared heading treatment for every top-level public page. */
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
- {subtitle && <p>{subtitle}</p>}
29
- {children && <div className="page-intro-actions">{children}</div>}
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
- // The package demo (no content/bookshelf.yaml) is the approved chrome-less viewport.
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 shelf={bookshelfSite} />
22
+ <SiteHeader />
37
23
  <div id="main-content">{children}</div>
38
24
  <SiteFooter docs={isHandbookPath(pathname)} lang={lang} />
39
25
  </>