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.
- package/app/components/BlogArchive.tsx +14 -2
- package/app/components/MarkdownArticle.tsx +17 -4
- package/app/globals.css +8 -6
- package/app/lib/content.ts +1 -1
- 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
|
@@ -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={{
|
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;
|
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/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) {
|