hd-garden 0.5.13 → 0.5.14
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/ArticleTags.tsx +5 -3
- package/app/components/MarkdownArticle.tsx +29 -0
- package/app/components/SiteHeader.tsx +12 -14
- package/app/components/TagRedirect.tsx +3 -3
- package/app/lib/tag-filter.ts +8 -0
- package/app/styles/shell.css +20 -2
- package/app/tags/page.tsx +2 -2
- package/package.json +1 -1
- package/scripts/redirects.mjs +3 -38
- package/ui/garden/internal-links.mjs +111 -0
- package/ui/garden/same-site.mjs +27 -0
- package/ui/garden/site-nav.mjs +25 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { articleTagHref } from "../lib/tag-filter";
|
|
2
3
|
|
|
4
|
+
/** Tags under an article title. Link, so following one is a client navigation like the rest of the site. */
|
|
3
5
|
export function ArticleTags({ tags }: { tags?: string[] }) {
|
|
4
6
|
const list = (tags ?? []).filter(Boolean);
|
|
5
7
|
if (!list.length) return null;
|
|
@@ -7,9 +9,9 @@ export function ArticleTags({ tags }: { tags?: string[] }) {
|
|
|
7
9
|
<ul className="article-tagrow" aria-label="标签">
|
|
8
10
|
{list.map((tag) => (
|
|
9
11
|
<li key={tag}>
|
|
10
|
-
<
|
|
12
|
+
<Link className="tag-chip p-category" href={articleTagHref(tag)}>
|
|
11
13
|
#{tag}
|
|
12
|
-
</
|
|
14
|
+
</Link>
|
|
13
15
|
</li>
|
|
14
16
|
))}
|
|
15
17
|
</ul>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ReactNode,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { articleHeadingBase, articleUsesHeadingNumbers, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
|
|
12
|
+
import Link from "next/link";
|
|
12
13
|
import ReactMarkdown from "react-markdown";
|
|
13
14
|
import rehypeRaw from "rehype-raw";
|
|
14
15
|
import rehypeKatex from "rehype-katex";
|
|
@@ -24,6 +25,8 @@ import { imageSizes, linkAssets } from "@garden/generated-content";
|
|
|
24
25
|
import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs";
|
|
25
26
|
import { siteConfig, siteLinks } from "@garden/site-config";
|
|
26
27
|
import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
|
|
28
|
+
import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
|
|
29
|
+
import { columnHref, columns, postHref, posts, thoughts } from "../lib/content";
|
|
27
30
|
import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
28
31
|
import { linkHost } from "../../scripts/link-meta-core.mjs";
|
|
29
32
|
import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
@@ -31,6 +34,22 @@ import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
|
31
34
|
// Full URLs back to this site are internal links, written as root-relative paths.
|
|
32
35
|
const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
|
|
33
36
|
|
|
37
|
+
// Links to other pages of the site render as Link. A link to an old address listed under
|
|
38
|
+
// redirects is rewritten to the page it moved to; a link that lands on no known page stays a plain anchor.
|
|
39
|
+
const internalRoute = internalLinkResolver({
|
|
40
|
+
redirects: [
|
|
41
|
+
...readRedirects(siteConfig),
|
|
42
|
+
...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
|
|
43
|
+
],
|
|
44
|
+
routes: [
|
|
45
|
+
"/", "/blog", "/columns", "/thoughts", "/tags", "/about",
|
|
46
|
+
...posts.map(postHref),
|
|
47
|
+
...columns.map(columnHref),
|
|
48
|
+
...thoughts.map((thought) => `/thoughts/${thought.slug}`),
|
|
49
|
+
],
|
|
50
|
+
basePath: siteLinks?.basePath ?? "",
|
|
51
|
+
});
|
|
52
|
+
|
|
34
53
|
/** The site's favicon, downloaded at build time; nothing when there is none. */
|
|
35
54
|
function Favicon({ href }: { href: string }) {
|
|
36
55
|
const src = linkAssets?.favicons?.[linkHost(href)];
|
|
@@ -262,6 +281,16 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
262
281
|
const { href, external } = articleLink(written, sameSite);
|
|
263
282
|
const domProps = { ...props };
|
|
264
283
|
Reflect.deleteProperty(domProps, "node");
|
|
284
|
+
// A link to another page of the site navigates in place, like the header and lists do.
|
|
285
|
+
// No viewport prefetch: a long article can link to many pages.
|
|
286
|
+
const route = external || domProps.target || domProps.download !== undefined ? null : internalRoute(href);
|
|
287
|
+
if (route) {
|
|
288
|
+
return (
|
|
289
|
+
<Link href={route} prefetch={false} {...domProps}>
|
|
290
|
+
{children}
|
|
291
|
+
</Link>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
265
294
|
return (
|
|
266
295
|
<a
|
|
267
296
|
href={href}
|
|
@@ -16,6 +16,7 @@ import { brand, searchFull, searchIndexUrl } from "@garden/site-config";
|
|
|
16
16
|
import { createSearch } from "../../ui/garden/search-match.mjs";
|
|
17
17
|
import { cropMatchedText, highlightParts, noResultsText } from "../../ui/garden/search-preview.mjs";
|
|
18
18
|
import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
|
|
19
|
+
import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
|
|
19
20
|
import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
|
|
20
21
|
import { siteConfig } from "../site.config";
|
|
21
22
|
|
|
@@ -85,13 +86,7 @@ function loadSearchEngine() {
|
|
|
85
86
|
return engineLoad;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
const nav =
|
|
89
|
-
{ href: "/blog", label: "文章" },
|
|
90
|
-
{ href: "/thoughts", label: "随想" },
|
|
91
|
-
{ href: "/columns", label: "专栏" },
|
|
92
|
-
{ href: "/tags", label: "标签" },
|
|
93
|
-
{ href: "/about", label: "关于" },
|
|
94
|
-
];
|
|
89
|
+
const nav = SITE_NAV;
|
|
95
90
|
|
|
96
91
|
function rangesFor(
|
|
97
92
|
matches: readonly SearchMatch[] | undefined,
|
|
@@ -302,16 +297,11 @@ export function SiteHeader() {
|
|
|
302
297
|
window.localStorage.setItem("theme", next ? "dark" : "light");
|
|
303
298
|
};
|
|
304
299
|
|
|
305
|
-
const current = (href: string) =>
|
|
306
|
-
if (href === "/blog") {
|
|
307
|
-
return pathname === "/blog" || pathname.startsWith("/blog/") || pathname.startsWith("/a2/");
|
|
308
|
-
}
|
|
309
|
-
return pathname === href || pathname.startsWith(`${href}/`);
|
|
310
|
-
};
|
|
300
|
+
const current = (href: string) => isCurrentNav(pathname, href);
|
|
311
301
|
|
|
312
302
|
return (
|
|
313
303
|
<>
|
|
314
|
-
<header className="sh-header">
|
|
304
|
+
<header className="sh-header" id="site-header">
|
|
315
305
|
<div className="sh-bar">
|
|
316
306
|
<Link href="/" className="sh-word">
|
|
317
307
|
{/* Build-time SVG from brand.logo, cleaned of scripts and handlers. */}
|
|
@@ -352,6 +342,14 @@ export function SiteHeader() {
|
|
|
352
342
|
<button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ? "关闭菜单" : "打开菜单"} onClick={() => setMenuOpen((open) => !open)}>
|
|
353
343
|
{menuOpen ? <X size={18} /> : <span aria-hidden="true">☰</span>}
|
|
354
344
|
</button>
|
|
345
|
+
{/* Without JavaScript the button above cannot open anything: these two links take its place
|
|
346
|
+
and open or close the menu through :target, like the TOC drawer (shell.css). */}
|
|
347
|
+
<a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu" aria-label="打开菜单">
|
|
348
|
+
<span aria-hidden="true">☰</span>
|
|
349
|
+
</a>
|
|
350
|
+
<a className="sh-icon sh-menu-nojs-close" href="#site-header" aria-label="关闭菜单">
|
|
351
|
+
<X size={18} />
|
|
352
|
+
</a>
|
|
355
353
|
</div>
|
|
356
354
|
</div>
|
|
357
355
|
<nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label="站点导航(移动端)">
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
4
4
|
import { useEffect } from "react";
|
|
5
|
-
import { TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
|
|
5
|
+
import { ARTICLE_TAG_LIST, TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
|
|
6
6
|
|
|
7
|
-
/** Old `/tags?t=X` links open the same filter on `/blog
|
|
7
|
+
/** Old `/tags?t=X` links open the same filter on `/blog/?t=X`. */
|
|
8
8
|
export function TagRedirect() {
|
|
9
9
|
const router = useRouter();
|
|
10
10
|
const params = useSearchParams();
|
|
11
11
|
const tags = selectedTags(params.getAll(TAG_PARAM));
|
|
12
|
-
const target = tags.length ? tagFilterHref(tags) : "";
|
|
12
|
+
const target = tags.length ? tagFilterHref(tags, ARTICLE_TAG_LIST) : "";
|
|
13
13
|
useEffect(() => {
|
|
14
14
|
if (target) router.replace(target);
|
|
15
15
|
}, [router, target]);
|
package/app/lib/tag-filter.ts
CHANGED
|
@@ -145,6 +145,14 @@ export function tagFilterHref(selected: readonly string[], pathname = "/blog") {
|
|
|
145
145
|
return query ? `${pathname}?${query}` : pathname;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Where a tag link points (tags on an article, the /tags page): the list page at its exported address
|
|
150
|
+
* (`/blog/`, with the slash, so the static host does not answer /blog?t=… with
|
|
151
|
+
* a 301 first), filtered to that one tag. The tag is URL-encoded.
|
|
152
|
+
*/
|
|
153
|
+
export const ARTICLE_TAG_LIST = "/blog/";
|
|
154
|
+
export const articleTagHref = (tag: string) => tagFilterHref([tag], ARTICLE_TAG_LIST);
|
|
155
|
+
|
|
148
156
|
/** 「共 N 篇」, or 「2 个标签,共 5 篇」 with a selection. */
|
|
149
157
|
export function tagSummary(selectedCount: number, resultCount: number) {
|
|
150
158
|
return selectedCount ? `${selectedCount} 个标签,共 ${resultCount} 篇` : `共 ${resultCount} 篇`;
|
package/app/styles/shell.css
CHANGED
|
@@ -65,10 +65,9 @@ html[data-mod="other"] .sh-search .kbd-other{display:inline}
|
|
|
65
65
|
.sh-icon{display:inline-grid;place-items:center;width:40px;height:40px;border:0;background:transparent;color:var(--soft);border-radius:var(--r);cursor:pointer;transition:background-color .15s ease,color .15s ease}
|
|
66
66
|
@media (hover:hover){.sh-icon:hover{background:var(--tint);color:var(--ink)}}
|
|
67
67
|
.sh-menu-btn,.sh-search-m{display:none}
|
|
68
|
+
.sh-menu-nojs,.sh-menu-nojs-close{display:none}
|
|
68
69
|
.i-sun,html[data-theme="dark"] .i-moon{display:none}
|
|
69
70
|
html[data-theme="dark"] .i-sun{display:inline}
|
|
70
|
-
/* No JavaScript: the theme cannot be switched, the page follows the system (build/dark-fallback-postcss.mjs). */
|
|
71
|
-
@media (scripting:none){.sh-theme,.sh-menu-theme{display:none}}
|
|
72
71
|
.sh-menu{display:none}
|
|
73
72
|
|
|
74
73
|
/* ---------- page frame ---------- */
|
|
@@ -304,6 +303,25 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
|
|
|
304
303
|
.sh-pal-foot{display:none}
|
|
305
304
|
.sh-pal-input{font-size:17px;height:56px}
|
|
306
305
|
}
|
|
306
|
+
/* No JavaScript: the theme cannot be switched, the page follows the system (build/dark-fallback-postcss.mjs).
|
|
307
|
+
Keep this after every rule that shows the toggles (the 820px block above sets .sh-menu-theme to flex);
|
|
308
|
+
tests/header-nojs.test.mjs checks the order in the built CSS. */
|
|
309
|
+
@media (scripting:none){.sh-theme,.sh-menu-theme{display:none}}
|
|
310
|
+
/* Search needs JavaScript too: without it the desktop 「搜索」 button would do nothing. */
|
|
311
|
+
@media (scripting:none){.sh-search{display:none}}
|
|
312
|
+
/* No JavaScript: the ☰ button cannot open the menu, so a ☰ link in the same place opens it through :target,
|
|
313
|
+
as the TOC drawer does, and ✕ closes it by targeting the header. With JavaScript none of this applies. */
|
|
314
|
+
@media (max-width:820px) and (scripting:none){
|
|
315
|
+
.sh-menu-btn{display:none}
|
|
316
|
+
/* Search needs JavaScript (the index loads on demand); without it the button would do nothing. */
|
|
317
|
+
.sh-search-m{display:none}
|
|
318
|
+
.sh-menu-nojs{display:inline-grid;width:44px;height:44px;text-decoration:none}
|
|
319
|
+
/* The menu sits right under the sticky header: opening it should not scroll the page. */
|
|
320
|
+
.sh-menu{scroll-margin-top:var(--hdr)}
|
|
321
|
+
.sh-menu:target{opacity:1;transform:none;visibility:visible;transition:opacity .18s ease,transform .18s var(--ease)}
|
|
322
|
+
body:has(#sh-menu:target) .sh-menu-nojs{display:none}
|
|
323
|
+
body:has(#sh-menu:target) .sh-menu-nojs-close{display:inline-grid;width:44px;height:44px;text-decoration:none}
|
|
324
|
+
}
|
|
307
325
|
@media (prefers-reduced-motion:reduce){
|
|
308
326
|
*,*::before,*::after{animation:none!important;transition:none!important}
|
|
309
327
|
html{scroll-behavior:auto!important}
|
package/app/tags/page.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { Suspense } from "react";
|
|
3
3
|
import { posts } from "../lib/content";
|
|
4
|
-
import {
|
|
4
|
+
import { articleTagHref, tagCounts } from "../lib/tag-filter";
|
|
5
5
|
import { TagRedirect } from "../components/TagRedirect";
|
|
6
6
|
import { siteDocumentTitle } from "../site.config";
|
|
7
7
|
|
|
@@ -25,7 +25,7 @@ export default function TagsPage() {
|
|
|
25
25
|
<ul className="sh-tagchips sh-tagindex" aria-label="全部标签">
|
|
26
26
|
{tags.map(([tag, count]) => (
|
|
27
27
|
<li key={tag}>
|
|
28
|
-
<Link className="sh-tagchip" href={
|
|
28
|
+
<Link className="sh-tagchip" href={articleTagHref(tag)}>
|
|
29
29
|
#{tag}<span className="n num">{count}</span>
|
|
30
30
|
</Link>
|
|
31
31
|
</li>
|
package/package.json
CHANGED
package/scripts/redirects.mjs
CHANGED
|
@@ -23,44 +23,9 @@ import { configuredSiteAddress } from "../ui/garden/same-site.mjs";
|
|
|
23
23
|
|
|
24
24
|
const escapeHtml = (value) => String(value).replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
if (!map) return [];
|
|
30
|
-
if (typeof map !== "object" || Array.isArray(map)) {
|
|
31
|
-
warn("redirects 应写成「旧路径: 新地址」的映射,已忽略。");
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
const pairs = [];
|
|
35
|
-
for (const [rawFrom, rawTo] of Object.entries(map)) {
|
|
36
|
-
let from = String(rawFrom).trim();
|
|
37
|
-
const to = String(rawTo ?? "").trim();
|
|
38
|
-
try {
|
|
39
|
-
from = decodeURI(from);
|
|
40
|
-
} catch {
|
|
41
|
-
// keep as written
|
|
42
|
-
}
|
|
43
|
-
if (!from.startsWith("/") || from.startsWith("//") || from === "/" || /(^|\/)\.\.?(\/|$)/u.test(from) || /[?#]/u.test(from)) {
|
|
44
|
-
warn(`redirects:旧路径 ${rawFrom} 必须是站内路径(以 / 开头,不带 ?、# 和 ..),已跳过。`);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
if (!(to.startsWith("/") && !to.startsWith("//")) && !/^https?:\/\//iu.test(to)) {
|
|
48
|
-
warn(`redirects:${rawFrom} 的新地址 ${rawTo} 应以 / 开头或是完整网址,已跳过。`);
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
pairs.push({ from, to });
|
|
52
|
-
}
|
|
53
|
-
return pairs;
|
|
54
|
-
}
|
|
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
|
-
}
|
|
26
|
+
// Shared with the article renderer, which rewrites links to old addresses (ui/garden/internal-links.mjs).
|
|
27
|
+
export { columnRedirects, readRedirects } from "../ui/garden/internal-links.mjs";
|
|
28
|
+
import { columnRedirects, readRedirects } from "../ui/garden/internal-links.mjs";
|
|
64
29
|
|
|
65
30
|
/** The file an old path is served from: `/a/b/` and `/a/b` → a/b/index.html, `/a/b.html` → a/b.html. */
|
|
66
31
|
export function redirectFile(from) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-site links in article bodies.
|
|
3
|
+
*
|
|
4
|
+
* `redirects` in site.config.yaml maps old addresses to where pages live now,
|
|
5
|
+
* and a static export answers the old address with a small meta-refresh page
|
|
6
|
+
* (scripts/redirects.mjs). A client navigation cannot follow that page: there
|
|
7
|
+
* is no RSC payload at the old address. So when an article links to an old
|
|
8
|
+
* address, the rendered link points straight at the new one, keeping the
|
|
9
|
+
* link's own ?query and #hash.
|
|
10
|
+
*
|
|
11
|
+
* `internalRoute` returns the address to give the framework Link, or null to
|
|
12
|
+
* keep a plain anchor: a link that leaves the site, ends on a file, runs into a
|
|
13
|
+
* redirect loop or an external target, or lands on no known page.
|
|
14
|
+
*/
|
|
15
|
+
import { clientRoute } from "./same-site.mjs";
|
|
16
|
+
|
|
17
|
+
/** Valid `{ from, to }` pairs from the config; anything malformed is reported and skipped. */
|
|
18
|
+
export function readRedirects(config = {}, warn = () => {}) {
|
|
19
|
+
const map = config?.redirects;
|
|
20
|
+
if (!map) return [];
|
|
21
|
+
if (typeof map !== "object" || Array.isArray(map)) {
|
|
22
|
+
warn("redirects 应写成「旧路径: 新地址」的映射,已忽略。");
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const pairs = [];
|
|
26
|
+
for (const [rawFrom, rawTo] of Object.entries(map)) {
|
|
27
|
+
let from = String(rawFrom).trim();
|
|
28
|
+
const to = String(rawTo ?? "").trim();
|
|
29
|
+
try {
|
|
30
|
+
from = decodeURI(from);
|
|
31
|
+
} catch {
|
|
32
|
+
// keep as written
|
|
33
|
+
}
|
|
34
|
+
if (!from.startsWith("/") || from.startsWith("//") || from === "/" || /(^|\/)\.\.?(\/|$)/u.test(from) || /[?#]/u.test(from)) {
|
|
35
|
+
warn(`redirects:旧路径 ${rawFrom} 必须是站内路径(以 / 开头,不带 ?、# 和 ..),已跳过。`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!(to.startsWith("/") && !to.startsWith("//")) && !/^https?:\/\//iu.test(to)) {
|
|
39
|
+
warn(`redirects:${rawFrom} 的新地址 ${rawTo} 应以 / 开头或是完整网址,已跳过。`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
pairs.push({ from, to });
|
|
43
|
+
}
|
|
44
|
+
return pairs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `/columns/<slug>/` → `/columns/#<slug>` for every valid column slug in columns.yaml. */
|
|
48
|
+
export function columnRedirects(columnsConfig = {}) {
|
|
49
|
+
const columns = Array.isArray(columnsConfig?.columns) ? columnsConfig.columns : [];
|
|
50
|
+
return columns
|
|
51
|
+
.map((column) => (column && typeof column.slug === "string" ? column.slug.trim() : ""))
|
|
52
|
+
.filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug))
|
|
53
|
+
.map((slug) => ({ from: `/columns/${slug}/`, to: `/columns/#${slug}` }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One key per page address: decoded, without a trailing slash (`/` stays `/`). */
|
|
57
|
+
export function routeKey(pathname) {
|
|
58
|
+
let value = String(pathname ?? "");
|
|
59
|
+
try {
|
|
60
|
+
value = decodeURI(value);
|
|
61
|
+
} catch {
|
|
62
|
+
// keep as written
|
|
63
|
+
}
|
|
64
|
+
return value.length > 1 ? value.replace(/\/+$/u, "") || "/" : value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const splitHref = (href) => {
|
|
68
|
+
const hashAt = href.indexOf("#");
|
|
69
|
+
const beforeHash = hashAt < 0 ? href : href.slice(0, hashAt);
|
|
70
|
+
const hash = hashAt < 0 ? "" : href.slice(hashAt);
|
|
71
|
+
const queryAt = beforeHash.indexOf("?");
|
|
72
|
+
return { pathname: queryAt < 0 ? beforeHash : beforeHash.slice(0, queryAt), search: queryAt < 0 ? "" : beforeHash.slice(queryAt), hash };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the resolver once per render.
|
|
77
|
+
* - redirects: `{ from, to }` pairs (readRedirects + columnRedirects).
|
|
78
|
+
* - routes: every page address the site builds, without the base path.
|
|
79
|
+
* - basePath: NEXT_PUBLIC_BASE_PATH; Link adds it back.
|
|
80
|
+
*/
|
|
81
|
+
export function internalLinkResolver({ redirects = [], routes = [], basePath = "" } = {}) {
|
|
82
|
+
const moved = new Map();
|
|
83
|
+
for (const { from, to } of redirects) if (!moved.has(routeKey(from))) moved.set(routeKey(from), to);
|
|
84
|
+
const pages = new Set([...routes].map(routeKey));
|
|
85
|
+
const base = String(basePath ?? "").trim().replace(/\/+$/u, "");
|
|
86
|
+
return function internalRoute(href) {
|
|
87
|
+
if (typeof href !== "string") return null;
|
|
88
|
+
let value = href.trim();
|
|
89
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
90
|
+
if (base) {
|
|
91
|
+
if (value !== base && !value.startsWith(`${base}/`) && !value.startsWith(`${base}?`) && !value.startsWith(`${base}#`)) return null;
|
|
92
|
+
value = value.slice(base.length) || "/";
|
|
93
|
+
if (!value.startsWith("/")) value = `/${value}`;
|
|
94
|
+
}
|
|
95
|
+
const own = splitHref(value);
|
|
96
|
+
let target = own;
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
while (moved.has(routeKey(target.pathname))) {
|
|
99
|
+
const key = routeKey(target.pathname);
|
|
100
|
+
if (seen.has(key) || seen.size > 16) return null;
|
|
101
|
+
seen.add(key);
|
|
102
|
+
const to = moved.get(key);
|
|
103
|
+
if (!to.startsWith("/") || to.startsWith("//")) return null;
|
|
104
|
+
const next = splitHref(to);
|
|
105
|
+
// The link's own ?query and #hash win; otherwise the redirect target's.
|
|
106
|
+
target = { pathname: next.pathname || "/", search: own.search || next.search, hash: own.hash || next.hash };
|
|
107
|
+
}
|
|
108
|
+
if (!pages.has(routeKey(target.pathname))) return null;
|
|
109
|
+
return clientRoute(`${target.pathname}${target.search}${target.hash}`);
|
|
110
|
+
};
|
|
111
|
+
}
|
package/ui/garden/same-site.mjs
CHANGED
|
@@ -61,3 +61,30 @@ export function articleLink(href, sameSite) {
|
|
|
61
61
|
if (local !== null) return { href: local, external: false };
|
|
62
62
|
return { href, external: /^(https?:)?\/\//iu.test(href) };
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/** Static files the site serves next to its pages; these always load as files. */
|
|
66
|
+
const FILE_PREFIXES = ["/api/", "/assets/", "/images/", "/media/", "/link-assets/", "/_next/", "/fonts/"];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The route to hand to the framework Link for an in-article link, or null to
|
|
70
|
+
* keep a plain anchor. Only a root-relative page address qualifies: no
|
|
71
|
+
* protocol or host, not just a hash, no file extension in the last segment,
|
|
72
|
+
* none of the static file folders. Link adds the base path itself, so it is
|
|
73
|
+
* taken off here; a path outside the base path stays a plain anchor.
|
|
74
|
+
*/
|
|
75
|
+
export function clientRoute(href, basePath = "") {
|
|
76
|
+
if (typeof href !== "string") return null;
|
|
77
|
+
const value = href.trim();
|
|
78
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
79
|
+
const base = normalizeBase(basePath);
|
|
80
|
+
const [pathname] = value.split(/[?#]/u);
|
|
81
|
+
let route = pathname;
|
|
82
|
+
if (base) {
|
|
83
|
+
if (route !== base && !route.startsWith(`${base}/`)) return null;
|
|
84
|
+
route = route.slice(base.length) || "/";
|
|
85
|
+
}
|
|
86
|
+
if (FILE_PREFIXES.some((prefix) => route.startsWith(prefix) || `${route}/` === prefix)) return null;
|
|
87
|
+
const last = route.split("/").filter(Boolean).at(-1) ?? "";
|
|
88
|
+
if (/\.[a-z0-9]{1,8}$/iu.test(last)) return null;
|
|
89
|
+
return `${route}${value.slice(pathname.length)}`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header's site navigation. Hrefs carry the trailing slash: a static
|
|
3
|
+
* export serves these pages at /blog/ and so on, and the host answers /blog
|
|
4
|
+
* with a 301 first (new tab, no JavaScript, crawlers).
|
|
5
|
+
*/
|
|
6
|
+
export const SITE_NAV = [
|
|
7
|
+
{ href: "/blog/", label: "文章" },
|
|
8
|
+
{ href: "/thoughts/", label: "随想" },
|
|
9
|
+
{ href: "/columns/", label: "专栏" },
|
|
10
|
+
{ href: "/tags/", label: "标签" },
|
|
11
|
+
{ href: "/about/", label: "关于" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const trim = (value) => (value.length > 1 ? value.replace(/\/+$/u, "") : value);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a nav item is the current section. The pathname may come with or
|
|
18
|
+
* without the trailing slash; articles count as 文章 (/blog/…, /a2/…).
|
|
19
|
+
*/
|
|
20
|
+
export function isCurrentNav(pathname, href) {
|
|
21
|
+
const path = trim(pathname || "/");
|
|
22
|
+
const section = trim(href);
|
|
23
|
+
if (section === "/blog") return path === "/blog" || path.startsWith("/blog/") || path.startsWith("/a2/");
|
|
24
|
+
return path === section || path.startsWith(`${section}/`);
|
|
25
|
+
}
|