hd-garden 0.5.6 → 0.5.7

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
5
- import { useMemo, useState } from "react";
5
+ import { useEffect, useMemo, useState } from "react";
6
6
  import type { ArchivePost, ArchiveSeries } from "../lib/archive";
7
7
  import {
8
8
  TAG_PARAM,
@@ -30,7 +30,7 @@ function day(value: string) {
30
30
 
31
31
  // Kept local: importing ../lib/content here would pull every post body into the client bundle.
32
32
  const postHref = (post: Pick<ArchivePost, "path">) => `/blog/${post.path}`;
33
- const columnIndexHref = (slug: string) => `/columns#${slug}`;
33
+ const columnIndexHref = (slug: string) => `/columns/#${slug}`;
34
34
 
35
35
  function ColumnLabel({ column }: { column?: ArchivePost["column"] }) {
36
36
  return column ? <Link className="cat" href={columnIndexHref(column.slug)}>{column.title}</Link> : null;
@@ -47,6 +47,18 @@ export function BlogArchive({ posts, series }: ArchiveData) {
47
47
  const searchParams = useSearchParams();
48
48
  const paramKey = searchParams.getAll(TAG_PARAM).join("\u0000");
49
49
  const selected = useMemo(() => canonicalTags(posts, selectedTags(paramKey ? paramKey.split("\u0000") : [])), [paramKey, posts]);
50
+ // This list replaces the static fallback after scripts load, which can undo
51
+ // the browser's jump to a #year link. Jump again once, to the same place the
52
+ // browser would (scrollIntoView honours the page's scroll-padding).
53
+ useEffect(() => {
54
+ let id = "";
55
+ try {
56
+ id = decodeURIComponent(window.location.hash.slice(1));
57
+ } catch {
58
+ return;
59
+ }
60
+ if (id) document.getElementById(id)?.scrollIntoView({ block: "start" });
61
+ }, []);
50
62
  return <ArchiveView posts={posts} series={series} selected={selected} go={(next) => router.push(tagFilterHref(next, pathname), { scroll: false })} />;
51
63
  }
52
64
 
@@ -39,10 +39,23 @@ function Favicon({ href }: { href: string }) {
39
39
  return <img className="fav" src={src} alt="" width={14} height={14} loading="lazy" decoding="async" aria-hidden="true" />;
40
40
  }
41
41
 
42
- /** The first word of a link (or its first CJK character) and the rest. */
42
+ const CJK = "\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}";
43
+ const FIRST_WORD = new RegExp(`^(\\s*(?:[${CJK}]|[^\\s${CJK}]+))([\\s\\S]*)$`, "u");
44
+ const WORD_HEAD = new RegExp(`^(\\s*[\\p{L}\\p{N}_]+[^\\s\\p{L}\\p{N}_]?)([\\s\\S]*)$`, "u");
45
+
46
+ /**
47
+ * The first word of a link (or its first CJK character) and the rest. A very
48
+ * long first word, such as a bare URL or a path, keeps only its head
49
+ * ("https:") with the icon, so the rest of it can wrap.
50
+ */
43
51
  const firstWord = (text: string) => {
44
- const match = /^(\s*(?:[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]|[^\s\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+))([\s\S]*)$/u.exec(text);
45
- return match ? [match[1], match[2]] : [text, ""];
52
+ const match = FIRST_WORD.exec(text);
53
+ if (!match) return [text, ""];
54
+ if (match[1].trim().length > 24) {
55
+ const head = WORD_HEAD.exec(match[1]);
56
+ if (head) return [head[1], head[2] + match[2]];
57
+ }
58
+ return [match[1], match[2]];
46
59
  };
47
60
 
48
61
  /** Icon plus the link's first word on one line, so the icon never ends a line alone. */
@@ -231,7 +244,7 @@ export function MarkdownArticle({ content }: { content: string }) {
231
244
  rehypeRaw,
232
245
  rehypeKatex,
233
246
  rehypeSlug,
234
- [rehypeAutolinkHeadings, { behavior: "wrap" }],
247
+ [rehypeAutolinkHeadings, { behavior: "wrap", properties: { className: ["h-anchor"] } }],
235
248
  rehypeHighlight,
236
249
  ]}
237
250
  components={{
package/app/globals.css CHANGED
@@ -1519,7 +1519,6 @@ button {
1519
1519
  padding: 24px 26px 26px;
1520
1520
  background: var(--card);
1521
1521
  border: 1px solid var(--line);
1522
- scroll-margin-top: 120px;
1523
1522
  }
1524
1523
 
1525
1524
  .thought-card > header {
@@ -5496,6 +5495,14 @@ html[data-theme="dark"] .article-references {
5496
5495
  }
5497
5496
  }
5498
5497
 
5498
+ /* Without JavaScript every sub-list stays open and the collapse toggles do
5499
+ nothing, so hide them at every width. */
5500
+ @media (scripting: none) {
5501
+ .toc-section-toggle {
5502
+ display: none;
5503
+ }
5504
+ }
5505
+
5499
5506
  /* No JavaScript: the drawer button cannot open anything, so swap in plain
5500
5507
  links that open the drawer through :target. A TOC link moves the target to
5501
5508
  the heading, which closes the drawer and jumps there natively. */
@@ -5516,11 +5523,6 @@ html[data-theme="dark"] .article-references {
5516
5523
  text-decoration: none;
5517
5524
  }
5518
5525
 
5519
- /* Sub-lists are all open without JS, so the collapse toggles have no job. */
5520
- .toc-section-toggle {
5521
- display: none;
5522
- }
5523
-
5524
5526
  /* Same dim as the JS drawer; taps pass through to the page behind. */
5525
5527
  body:has(.toc:target) .toc-mobile-backdrop {
5526
5528
  opacity: 1;
@@ -32,7 +32,7 @@ export const columnOfPost = (post: Pick<ContentEntry, "path">) => {
32
32
  const entry = columns.find((item) => item.path === post.path && item.column);
33
33
  return entry?.column ? { slug: entry.column, title: entry.columnTitle ?? entry.column } : undefined;
34
34
  };
35
- export const columnIndexHref = (slug: string) => `/columns#${slug}`;
35
+ export const columnIndexHref = (slug: string) => `/columns/#${slug}`;
36
36
 
37
37
  export const formatDate = (value: string, withTime = false) => {
38
38
  if (!withTime) return demoDateLabel(value);
package/app/page.tsx CHANGED
@@ -116,7 +116,7 @@ export default function Home() {
116
116
  ) : null}
117
117
  </div>
118
118
  <div className="body">
119
- <h3><Link href={`/columns#${slug}`}>{first.columnTitle}</Link></h3>
119
+ <h3><Link href={`/columns/#${slug}`}>{first.columnTitle}</Link></h3>
120
120
  <p className="count">{entries.length} 篇,{first.columnStatus ?? "持续更新"}</p>
121
121
  <p>{first.columnDescription ?? first.description}</p>
122
122
  </div>
package/app/styles/a2.css CHANGED
@@ -132,12 +132,13 @@ body {
132
132
  max-width: 1200px;
133
133
  margin: 0 auto;
134
134
  text-wrap: balance;
135
+ /* Titles wrap; an English word only breaks if it alone cannot fit. */
136
+ overflow-wrap: break-word;
135
137
  }
136
138
 
137
139
  @media (min-width: 1240px) {
138
140
  .hero h1:not(.h1-br),
139
141
  .article-header h1:not(.h1-br) {
140
- white-space: nowrap;
141
142
  max-width: none;
142
143
  }
143
144
  }
@@ -150,7 +151,6 @@ body {
150
151
  .h1-line {
151
152
  display: block;
152
153
  text-wrap: balance;
153
- word-break: keep-all;
154
154
  overflow-wrap: break-word;
155
155
  }
156
156
 
@@ -168,10 +168,6 @@ body {
168
168
  max-width: none;
169
169
  font-size: min(var(--fs-hero), calc(min(100vw - 80px, 1276px) / var(--l)));
170
170
  }
171
-
172
- .h1-line {
173
- white-space: nowrap;
174
- }
175
171
  }
176
172
 
177
173
  .article-meta,
@@ -390,6 +386,22 @@ html[data-theme="dark"] .column-reading-page .e-content {
390
386
  text-decoration-thickness: 2px;
391
387
  }
392
388
 
389
+ /* A heading's own anchor link reads as the heading: no underline in any
390
+ state. Keyboard focus keeps the ring. Links inside the heading (a.ext)
391
+ keep their own underline. */
392
+ .markdown-body .h-anchor,
393
+ .markdown-body .h-anchor:hover,
394
+ .markdown-body .h-anchor:active {
395
+ color: inherit;
396
+ text-decoration: none;
397
+ }
398
+
399
+ .markdown-body .h-anchor:focus-visible {
400
+ outline: 2px solid var(--blue);
401
+ outline-offset: 2px;
402
+ border-radius: 2px;
403
+ }
404
+
393
405
  .markdown-body a.ext {
394
406
  text-decoration-style: dotted;
395
407
  text-decoration-thickness: 1.5px;
@@ -419,6 +431,12 @@ html[data-theme="dark"] .markdown-body a.ext {
419
431
  object-fit: contain;
420
432
  }
421
433
 
434
+ /* Only the icon and the first word stay together; the rest of a long link,
435
+ a bare URL included, wraps when the line runs out. */
436
+ .markdown-body a.ext {
437
+ overflow-wrap: break-word;
438
+ }
439
+
422
440
  .markdown-body a.ext .fav-keep {
423
441
  white-space: nowrap;
424
442
  }
@@ -45,8 +45,9 @@ img,svg{max-width:100%}
45
45
  .sh-nav a:hover{color:var(--ink);background:var(--tint)}
46
46
  .sh-nav a[aria-current="page"],.sh-nav a[aria-current="true"]{color:var(--blue);font-weight:600}
47
47
  .sh-tools{display:flex;align-items:center;gap:8px}
48
- .sh-search{display:inline-flex;align-items:center;gap:6px;height:38px;padding:8px 12px;border:0;background:transparent;color:var(--soft);border-radius:var(--r);font:400 15px/1 var(--sans);cursor:pointer;appearance:none;transition:color .15s ease,background-color .15s ease}
49
- .sh-search:hover{color:var(--ink);background:var(--tint)}
48
+ /* A hairline box so the entry reads as a field, not a stray word. */
49
+ .sh-search{display:inline-flex;align-items:center;gap:6px;box-sizing:border-box;height:34px;padding:0 10px 0 12px;border:1px solid var(--rule);background:transparent;color:var(--soft);border-radius:8px;font:400 15px/1 var(--sans);cursor:pointer;appearance:none;transition:color .15s ease,border-color .15s ease}
50
+ .sh-search:hover{color:var(--ink);border-color:var(--rule-strong)}
50
51
  .sh-search:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
51
52
  .sh-search svg,.sh-icon svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:none}
52
53
  .sh-search svg{width:16px;height:16px}
@@ -121,7 +122,7 @@ html[data-theme="dark"] .sh-rail{color:var(--muted)}
121
122
 
122
123
  /* ---------- post rows ---------- */
123
124
  .sh-year{margin:0}
124
- .sh-year > h2{font:700 var(--fs-lead)/1.3 var(--sans);color:var(--ink);margin:40px 0 0;padding:0 0 12px;border-bottom:1px solid var(--rule-strong);display:flex;align-items:baseline;gap:10px;scroll-margin-top:calc(var(--hdr) + 16px)}
125
+ .sh-year > h2{font:700 var(--fs-lead)/1.3 var(--sans);color:var(--ink);margin:40px 0 0;padding:0 0 12px;border-bottom:1px solid var(--rule-strong);display:flex;align-items:baseline;gap:10px}
125
126
  .sh-year > h2 .n{font:400 var(--fs-meta)/1 var(--sans);color:var(--muted)}
126
127
  .sh-year[hidden],.sh-row[hidden]{display:none}
127
128
  .sh-rows{list-style:none;margin:0;padding:0}
@@ -324,7 +325,7 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
324
325
  .sh-notice strong{color:var(--ink)}
325
326
  .sh-month > h2{font:700 var(--fs-lead)/1.3 var(--sans);color:var(--ink);margin:40px 0 0;padding:0 0 12px;border-bottom:1px solid var(--rule-strong);display:flex;align-items:baseline;gap:10px}
326
327
  .sh-month > h2 .n{font:400 var(--fs-meta)/1 var(--sans);color:var(--muted)}
327
- .sh-note{padding:28px 0 26px;border-bottom:1px solid var(--rule);scroll-margin-top:calc(var(--hdr) + 16px)}
328
+ .sh-note{padding:28px 0 26px;border-bottom:1px solid var(--rule)}
328
329
  .sh-note:target{background:linear-gradient(var(--blue-tint),var(--blue-tint)) no-repeat;background-size:100% 100%;box-shadow:-16px 0 0 var(--blue-tint),16px 0 0 var(--blue-tint)}
329
330
  .sh-note h3{margin:0 0 6px;font:700 var(--fs-body)/1.55 var(--sans);color:var(--ink)}
330
331
  .sh-note-body p{margin:0 0 .7em;font-size:var(--fs-body);line-height:1.8;color:var(--text)}
@@ -396,7 +397,7 @@ html[data-theme="dark"] .sh-repos > li.sk::after{background:linear-gradient(90de
396
397
  .sh-series{display:grid;grid-template-columns:240px minmax(0,760px);gap:0 40px;padding:36px 0;border-top:1px solid var(--rule)}
397
398
  .sh-series:first-of-type{border-top:0;padding-top:8px}
398
399
  .sh-series .cover{aspect-ratio:16/9;border-radius:var(--r);display:flex;align-items:center;justify-content:center;overflow:hidden;align-self:start}
399
- .sh-series h2{font:700 var(--fs-h2)/1.3 var(--sans);color:var(--ink);margin:0;scroll-margin-top:calc(var(--hdr) + 16px)}
400
+ .sh-series h2{font:700 var(--fs-h2)/1.3 var(--sans);color:var(--ink);margin:0}
400
401
  .sh-series .count{font-size:var(--fs-small);color:var(--muted);margin:6px 0 0}
401
402
  .sh-series .desc{font-size:var(--fs-body);line-height:1.7;color:var(--soft);margin:12px 0 0}
402
403
  .sh-series ol{margin:18px 0 0;padding:0;list-style:none}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -10,6 +10,10 @@
10
10
  * 301, so each old path gets a small page that sends the reader on: a meta
11
11
  * refresh, a script that keeps the #anchor, and rel="canonical" for search
12
12
  * engines. Runs after the build, into dist/client.
13
+ *
14
+ * Columns have no index page of their own: each column is a section of
15
+ * /columns/ whose heading id is the column slug. So every column in
16
+ * content/columns.yaml also gets /columns/<slug>/ → /columns/#<slug>.
13
17
  */
14
18
  import fs from "node:fs";
15
19
  import path from "node:path";
@@ -49,6 +53,15 @@ export function readRedirects(config = {}, warn = () => {}) {
49
53
  return pairs;
50
54
  }
51
55
 
56
+ /** `/columns/<slug>/` → `/columns/#<slug>` for every valid column slug in columns.yaml. */
57
+ export function columnRedirects(columnsConfig = {}) {
58
+ const columns = Array.isArray(columnsConfig?.columns) ? columnsConfig.columns : [];
59
+ return columns
60
+ .map((column) => (column && typeof column.slug === "string" ? column.slug.trim() : ""))
61
+ .filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug))
62
+ .map((slug) => ({ from: `/columns/${slug}/`, to: `/columns/#${slug}` }));
63
+ }
64
+
52
65
  /** The file an old path is served from: `/a/b/` and `/a/b` → a/b/index.html, `/a/b.html` → a/b.html. */
53
66
  export function redirectFile(from) {
54
67
  const clean = from.replace(/^\/+/u, "");
@@ -63,7 +76,7 @@ export function redirectPage({ href, canonical }) {
63
76
  <title>页面已移动</title>
64
77
  <link rel="canonical" href="${escapeHtml(canonical)}">
65
78
  <meta http-equiv="refresh" content="0; url=${escapeHtml(href)}">
66
- <script>location.replace(${JSON.stringify(href).replace(/</gu, "\\u003c")} + location.hash)</script>
79
+ <script>location.replace(${JSON.stringify(href).replace(/</gu, "\\u003c")}${href.includes("#") ? "" : " + location.hash"})</script>
67
80
  </head>
68
81
  <body>
69
82
  <p>页面已移到 <a href="${escapeHtml(href)}">${escapeHtml(canonical)}</a>。</p>
@@ -77,7 +90,7 @@ export function redirectPage({ href, canonical }) {
77
90
  * warning, when a real page already lives at the old path or when a site path
78
91
  * it points to was not built. Never throws for a bad entry.
79
92
  */
80
- export function writeRedirects({ outDir, config = {}, env = {}, basePath = "", log = () => {} }) {
93
+ export function writeRedirects({ outDir, config = {}, env = {}, basePath = "", log = () => {}, extra = [] }) {
81
94
  const base = String(basePath ?? "").replace(/\/+$/u, "");
82
95
  let origin = "";
83
96
  try {
@@ -87,7 +100,11 @@ export function writeRedirects({ outDir, config = {}, env = {}, basePath = "", l
87
100
  origin = "";
88
101
  }
89
102
  const written = [];
90
- for (const { from, to } of readRedirects(config, log)) {
103
+ const configured = readRedirects(config, log);
104
+ // Configured redirects win over generated ones for the same old path.
105
+ const taken = new Set(configured.map(({ from }) => redirectFile(from)));
106
+ const pairs = [...configured, ...extra.filter(({ from }) => !taken.has(redirectFile(from)))];
107
+ for (const { from, to } of pairs) {
91
108
  const file = path.join(outDir, redirectFile(from));
92
109
  if (!path.resolve(file).startsWith(path.resolve(outDir) + path.sep)) continue;
93
110
  if (fs.existsSync(file)) {
@@ -118,8 +135,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
118
135
  const outDir = path.join(siteRoot, "dist", "client");
119
136
  try {
120
137
  const config = parseYaml(fs.readFileSync(path.join(siteRoot, "site.config.yaml"), "utf8")) ?? {};
121
- if (config.redirects && fs.existsSync(outDir)) {
122
- const written = writeRedirects({ outDir, config, env: process.env, basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? "", log: (line) => process.stdout.write(`警告:${line}\n`) });
138
+ const columnsFile = path.join(siteRoot, "content", "columns.yaml");
139
+ const extra = fs.existsSync(columnsFile) ? columnRedirects(parseYaml(fs.readFileSync(columnsFile, "utf8")) ?? {}) : [];
140
+ if ((config.redirects || extra.length) && fs.existsSync(outDir)) {
141
+ const written = writeRedirects({ outDir, config, env: process.env, basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? "", log: (line) => process.stdout.write(`警告:${line}\n`), extra });
123
142
  process.stdout.write(`旧地址跳转页:${written.length} 个。\n`);
124
143
  }
125
144
  } catch (error) {