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.
- package/app/components/BlogArchive.tsx +14 -2
- package/app/components/MarkdownArticle.tsx +17 -4
- package/app/components/TableOfContents.tsx +10 -0
- package/app/globals.css +87 -9
- package/app/lib/content.ts +1 -1
- package/app/lib/remark-a2.mjs +6 -3
- package/app/page.tsx +1 -1
- package/app/styles/a2.css +24 -6
- package/app/styles/shell.css +6 -5
- package/package.json +1 -1
- package/scripts/redirects.mjs +24 -5
- package/ui/garden/compare-line.mjs +7 -3
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
45
|
-
|
|
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
|
|
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
|
+
}
|
package/app/lib/content.ts
CHANGED
|
@@ -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
|
|
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/lib/remark-a2.mjs
CHANGED
|
@@ -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
|
-
|
|
149
|
-
|
|
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
|
|
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
|
}
|
package/app/styles/shell.css
CHANGED
|
@@ -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
|
-
|
|
49
|
-
.sh-search
|
|
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
|
|
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)
|
|
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
|
|
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
package/scripts/redirects.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
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,
|
|
83
|
+
return { title: line.slice(0, start), separator: line.slice(start, end), text: line.slice(end) };
|
|
80
84
|
}
|
|
81
85
|
return null;
|
|
82
86
|
}
|