hd-garden 0.5.5 → 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={{
@@ -404,6 +404,13 @@ export function TableOfContents({
404
404
  <List size={17} weight="bold" aria-hidden="true" />
405
405
  <span>目录</span>
406
406
  </button>
407
+ {/* Without JavaScript the button above does nothing. This link opens the
408
+ drawer through :target instead; CSS shows it only when scripting is
409
+ off, so with JavaScript nothing here is visible. */}
410
+ <a className="toc-nojs-trigger" id={`${panelId}-open`} href={`#${panelId}`} aria-label={`打开${label}`}>
411
+ <List size={17} weight="bold" aria-hidden="true" />
412
+ <span>目录</span>
413
+ </a>
407
414
  <button
408
415
  className="toc-mobile-backdrop"
409
416
  type="button"
@@ -429,6 +436,9 @@ export function TableOfContents({
429
436
  >
430
437
  <X size={18} aria-hidden="true" />
431
438
  </button>
439
+ <a className="toc-nojs-close" href={`#${panelId}-open`} aria-label={`关闭${label}`}>
440
+ <X size={18} aria-hidden="true" />
441
+ </a>
432
442
  </div>
433
443
  <ol id={listId} ref={listRef} className="toc-list">
434
444
  {tree.map((node, index) => renderNode(node, [index]))}
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 {
@@ -1987,7 +1986,9 @@ button {
1987
1986
 
1988
1987
  .toc-mobile-trigger,
1989
1988
  .toc-mobile-backdrop,
1990
- .toc-mobile-close {
1989
+ .toc-mobile-close,
1990
+ .toc-nojs-trigger,
1991
+ .toc-nojs-close {
1991
1992
  display: none;
1992
1993
  }
1993
1994
 
@@ -3731,7 +3732,8 @@ html[data-theme="dark"] .article-references {
3731
3732
  overflow: visible;
3732
3733
  }
3733
3734
 
3734
- .toc-mobile-trigger {
3735
+ .toc-mobile-trigger,
3736
+ .toc-nojs-trigger {
3735
3737
  position: fixed;
3736
3738
  right: calc(16px + env(safe-area-inset-right));
3737
3739
  bottom: calc(16px + env(safe-area-inset-bottom));
@@ -3754,19 +3756,25 @@ html[data-theme="dark"] .article-references {
3754
3756
  }
3755
3757
 
3756
3758
  :root[data-theme="dark"] .toc-mobile-trigger,
3757
- html[data-theme="dark"] .toc-mobile-trigger {
3759
+ html[data-theme="dark"] .toc-mobile-trigger,
3760
+ :root[data-theme="dark"] .toc-nojs-trigger,
3761
+ html[data-theme="dark"] .toc-nojs-trigger {
3758
3762
  background: rgb(30 29 26 / 94%);
3759
3763
  border-color: var(--rule-strong);
3760
3764
  color: var(--ink);
3761
3765
  }
3762
3766
 
3763
3767
  .toc-mobile-trigger:hover,
3764
- .toc-mobile-trigger:focus-visible {
3768
+ .toc-mobile-trigger:focus-visible,
3769
+ .toc-nojs-trigger:hover,
3770
+ .toc-nojs-trigger:focus-visible {
3765
3771
  transform: translateY(-2px);
3766
3772
  }
3767
3773
 
3768
3774
  .toc-mobile-trigger:focus-visible,
3769
- .toc-mobile-close:focus-visible {
3775
+ .toc-mobile-close:focus-visible,
3776
+ .toc-nojs-trigger:focus-visible,
3777
+ .toc-nojs-close:focus-visible {
3770
3778
  outline: 2px solid var(--accent);
3771
3779
  outline-offset: 3px;
3772
3780
  }
@@ -3812,7 +3820,7 @@ html[data-theme="dark"] .article-references {
3812
3820
  max-height: min(72vh, 560px);
3813
3821
  max-height: min(72dvh, 560px);
3814
3822
  margin: 0;
3815
- padding: 0 24px calc(18px + env(safe-area-inset-bottom));
3823
+ padding: 0 0 calc(18px + env(safe-area-inset-bottom));
3816
3824
  display: flex;
3817
3825
  border: 0;
3818
3826
  border-top: 1px solid var(--line);
@@ -3849,7 +3857,8 @@ html[data-theme="dark"] .article-references {
3849
3857
  letter-spacing: 0.04em;
3850
3858
  }
3851
3859
 
3852
- .toc-mobile-close {
3860
+ .toc-mobile-close,
3861
+ .toc-nojs-close {
3853
3862
  width: 34px;
3854
3863
  height: 34px;
3855
3864
  padding: 0;
@@ -3862,14 +3871,31 @@ html[data-theme="dark"] .article-references {
3862
3871
  touch-action: manipulation;
3863
3872
  }
3864
3873
 
3865
- .toc-mobile-close:hover {
3874
+ .toc-mobile-close:hover,
3875
+ .toc-nojs-close:hover {
3866
3876
  color: var(--ink);
3867
3877
  }
3868
3878
 
3879
+ /* The no-JS links share the button styles above but stay hidden unless
3880
+ scripting is off (see the scripting: none block below). */
3881
+ .toc-nojs-trigger,
3882
+ .toc-nojs-close {
3883
+ display: none;
3884
+ }
3885
+
3869
3886
  .toc .toc-list {
3870
3887
  padding: 7px 4px 4px 0;
3871
3888
  }
3872
3889
 
3890
+ /* Keep the drawer's title and rows off the screen edge, in line with the
3891
+ body text. The inset lives on the header and the list rather than the
3892
+ panel, because the post layout's desktop rule overrides the panel's
3893
+ padding on post pages but not on column pages. */
3894
+ .toc .toc-header,
3895
+ .toc .toc-list {
3896
+ padding-inline: 20px;
3897
+ }
3898
+
3873
3899
  .toc li {
3874
3900
  margin: 10px 0;
3875
3901
  font-size: 13px;
@@ -5468,3 +5494,55 @@ html[data-theme="dark"] .article-references {
5468
5494
  justify-content: flex-start;
5469
5495
  }
5470
5496
  }
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
+
5506
+ /* No JavaScript: the drawer button cannot open anything, so swap in plain
5507
+ links that open the drawer through :target. A TOC link moves the target to
5508
+ the heading, which closes the drawer and jumps there natively. */
5509
+ @media (max-width: 900px) and (scripting: none) {
5510
+ .toc-mobile-trigger,
5511
+ .toc-mobile-close {
5512
+ display: none;
5513
+ }
5514
+
5515
+ .toc-nojs-trigger {
5516
+ display: inline-flex;
5517
+ color: var(--ink);
5518
+ text-decoration: none;
5519
+ }
5520
+
5521
+ .toc-nojs-close {
5522
+ display: grid;
5523
+ text-decoration: none;
5524
+ }
5525
+
5526
+ /* Same dim as the JS drawer; taps pass through to the page behind. */
5527
+ body:has(.toc:target) .toc-mobile-backdrop {
5528
+ opacity: 1;
5529
+ visibility: visible;
5530
+ transition: opacity 180ms ease, visibility 0s;
5531
+ }
5532
+
5533
+ body:has(.toc:target) .toc-nojs-trigger {
5534
+ opacity: 0;
5535
+ visibility: hidden;
5536
+ pointer-events: none;
5537
+ }
5538
+
5539
+ .reading-page > .toc:target,
5540
+ .post-reading-page .article-reading-layout .toc:target,
5541
+ .column-reading-page > .toc:target,
5542
+ .garden-page-toc .toc:target {
5543
+ visibility: visible;
5544
+ pointer-events: auto;
5545
+ transform: translateY(0);
5546
+ transition: transform 220ms ease, visibility 0s;
5547
+ }
5548
+ }
@@ -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);
@@ -145,10 +145,13 @@ function rewriteAlert(node) {
145
145
  let label = gfm ? gfm.label : "";
146
146
  let rest = gfm?.body ? [gfm.body, ...children.slice(1)] : children.slice(1);
147
147
  if (!label) {
148
- const custom = strongOnlyText(rest.find((child) => !isBlankParagraph(child)));
149
- if (custom) {
148
+ // A leading bold-only line is the title only when body text follows it.
149
+ // On its own it stays the body, under the default label.
150
+ const index = rest.findIndex((child) => !isBlankParagraph(child));
151
+ const custom = strongOnlyText(rest[index]);
152
+ const hasBody = rest.slice(index + 1).some((child) => !isBlankParagraph(child));
153
+ if (custom && hasBody) {
150
154
  label = custom;
151
- const index = rest.findIndex((child) => !isBlankParagraph(child));
152
155
  rest = rest.filter((_, childIndex) => childIndex !== index);
153
156
  }
154
157
  }
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.5",
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) {
@@ -1,7 +1,7 @@
1
1
  // A compare cell written as one line, 「X:Y」 or "X: Y". X is the title and Y
2
2
  // the text. The split is at the first full-width colon, or a colon followed by
3
3
  // a space, that is not inside inline code, link text or a link URL. The
4
- // separator and any space after it are returned as written, so
4
+ // separator, with the spaces on either side of it, is returned as written, so
5
5
  // title + separator + text always equals the original line.
6
6
 
7
7
  // End of a backtick code span starting at `start`, or -1 when it never closes.
@@ -72,11 +72,15 @@ export function splitCompareLine(line) {
72
72
  if (end > 0 && /^<[a-z][a-z0-9+.-]*:[^\s<>]*>$/iu.test(line.slice(at, end + 1))) { at = end; continue; }
73
73
  }
74
74
  const isSeparator = char === ":" || (char === ":" && line[at + 1] === " ");
75
- if (!isSeparator || at === 0) continue;
75
+ if (!isSeparator) continue;
76
+ // Spaces on either side of the colon belong to the separator.
77
+ let start = at;
78
+ while (start > 0 && /\s/u.test(line[start - 1])) start -= 1;
79
+ if (start === 0) continue;
76
80
  let end = at + 1;
77
81
  while (end < line.length && /\s/u.test(line[end])) end += 1;
78
82
  if (end >= line.length) return null;
79
- return { title: line.slice(0, at), separator: line.slice(at, end), text: line.slice(end) };
83
+ return { title: line.slice(0, start), separator: line.slice(start, end), text: line.slice(end) };
80
84
  }
81
85
  return null;
82
86
  }