hd-garden 0.5.1 → 0.5.2
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/MarkdownArticle.tsx +9 -5
- package/app/garden-modules.d.ts +3 -0
- package/app/globals.css +21 -4
- package/app/lib/remark-a2.mjs +4 -4
- package/app/lib/remark-link-card.mjs +11 -9
- package/app/styles/a2.css +12 -19
- package/app/styles/tokens.css +1 -1
- package/package.json +1 -1
- package/scripts/build-content.mjs +7 -1
- package/scripts/link-assets.mjs +15 -2
- package/templates/site/site.config.yaml +2 -0
- package/ui/garden/same-site.mjs +63 -0
|
@@ -21,11 +21,15 @@ import { CodeCopy } from "./garden/chrome";
|
|
|
21
21
|
import { CodeDiff } from "./garden/chrome";
|
|
22
22
|
import { GardenBlock } from "./garden/GardenBlock";
|
|
23
23
|
import { linkAssets } from "@garden/generated-content";
|
|
24
|
-
import { siteConfig } from "@garden/site-config";
|
|
24
|
+
import { siteConfig, siteLinks } from "@garden/site-config";
|
|
25
|
+
import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
|
|
25
26
|
import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
26
27
|
import { linkHost } from "../../scripts/link-meta-core.mjs";
|
|
27
28
|
import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
28
29
|
|
|
30
|
+
// Full URLs back to this site are internal links, written as root-relative paths.
|
|
31
|
+
const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
|
|
32
|
+
|
|
29
33
|
/** The site's favicon, downloaded at build time; nothing when there is none. */
|
|
30
34
|
function Favicon({ href }: { href: string }) {
|
|
31
35
|
const src = linkAssets?.favicons?.[linkHost(href)];
|
|
@@ -221,7 +225,7 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
221
225
|
return (
|
|
222
226
|
<div className="markdown-body prose">
|
|
223
227
|
<ReactMarkdown
|
|
224
|
-
remarkPlugins={[...markdownPlugins, remarkLinkCard, remarkA2]}
|
|
228
|
+
remarkPlugins={[...markdownPlugins, [remarkLinkCard, { sameSite }], remarkA2]}
|
|
225
229
|
rehypePlugins={[
|
|
226
230
|
rehypeRaw,
|
|
227
231
|
rehypeKatex,
|
|
@@ -237,11 +241,11 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
237
241
|
const only = nodes.length === 1 && isValidElement<{ href?: string; children?: ReactNode }>(nodes[0]) ? nodes[0] : null;
|
|
238
242
|
const href = only?.props.href;
|
|
239
243
|
const text = only ? headingText(only.props.children).trim() : "";
|
|
240
|
-
if (href && text === href && /^https?:\/\//iu.test(href)) return <LinkCard href={href} />;
|
|
244
|
+
if (href && text === href && /^https?:\/\//iu.test(href) && !sameSite(href)) return <LinkCard href={href} />;
|
|
241
245
|
return <p {...domProps}>{children}</p>;
|
|
242
246
|
},
|
|
243
|
-
a: ({ href, children, ...props }) => {
|
|
244
|
-
const external =
|
|
247
|
+
a: ({ href: written, children, ...props }) => {
|
|
248
|
+
const { href, external } = articleLink(written, sameSite);
|
|
245
249
|
const domProps = { ...props };
|
|
246
250
|
Reflect.deleteProperty(domProps, "node");
|
|
247
251
|
return (
|
package/app/garden-modules.d.ts
CHANGED
|
@@ -65,6 +65,8 @@ declare module "@garden/site-config" {
|
|
|
65
65
|
export const searchIndexUrl: string;
|
|
66
66
|
/** The full-text index file and the hash its contents are cached under. */
|
|
67
67
|
export const searchFull: { url: string; hash: string };
|
|
68
|
+
/** The site address (site.config.yaml `url`, else SITE_URL) and base path, for spotting links back to the site. */
|
|
69
|
+
export const siteLinks: { address: string; basePath: string };
|
|
68
70
|
export const siteConfig: {
|
|
69
71
|
name: string;
|
|
70
72
|
tagline: string;
|
|
@@ -74,6 +76,7 @@ declare module "@garden/site-config" {
|
|
|
74
76
|
pages: Record<string, Record<string, unknown>>;
|
|
75
77
|
footer: string;
|
|
76
78
|
linkPreview?: { worker?: string };
|
|
79
|
+
url?: string;
|
|
77
80
|
};
|
|
78
81
|
export const githubUrl: string;
|
|
79
82
|
}
|
package/app/globals.css
CHANGED
|
@@ -2061,15 +2061,32 @@ button {
|
|
|
2061
2061
|
text-decoration-thickness: 2px;
|
|
2062
2062
|
}
|
|
2063
2063
|
|
|
2064
|
-
/*
|
|
2065
|
-
.markdown-body u
|
|
2064
|
+
/* Highlighter for key claims, as in the A2 demo: a soft stroke under the lower part of the line. */
|
|
2065
|
+
.markdown-body u,
|
|
2066
|
+
.markdown-body mark,
|
|
2067
|
+
.markdown-body .hl {
|
|
2068
|
+
color: var(--ink);
|
|
2069
|
+
font-weight: 500;
|
|
2066
2070
|
text-decoration: none;
|
|
2067
|
-
border-bottom:
|
|
2068
|
-
|
|
2071
|
+
border-bottom: 0;
|
|
2072
|
+
background: linear-gradient(to bottom, transparent 58%, var(--hl) 58%, var(--hl) 92%, transparent 92%);
|
|
2073
|
+
padding: 0 0.08em;
|
|
2069
2074
|
-webkit-box-decoration-break: clone;
|
|
2070
2075
|
box-decoration-break: clone;
|
|
2071
2076
|
}
|
|
2072
2077
|
|
|
2078
|
+
.markdown-body u strong,
|
|
2079
|
+
.markdown-body u u,
|
|
2080
|
+
.markdown-body mark strong,
|
|
2081
|
+
.markdown-body mark u,
|
|
2082
|
+
.markdown-body .hl strong,
|
|
2083
|
+
.markdown-body .hl u {
|
|
2084
|
+
font-weight: 600;
|
|
2085
|
+
text-decoration: none;
|
|
2086
|
+
background: none;
|
|
2087
|
+
padding: 0;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2073
2090
|
.markdown-body strong {
|
|
2074
2091
|
color: var(--ink);
|
|
2075
2092
|
}
|
package/app/lib/remark-a2.mjs
CHANGED
|
@@ -18,10 +18,10 @@ const LEGACY_KIND = {
|
|
|
18
18
|
|
|
19
19
|
const ALERT_ICON = {
|
|
20
20
|
note: '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2v4"/><path d="M8 4.8v.1" stroke-width="2"/></svg>',
|
|
21
|
-
tip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="
|
|
22
|
-
important: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 3.25a1 1 0 0 1 1-
|
|
23
|
-
warning: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="
|
|
24
|
-
caution: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.3 1.75h5.4l3.55 3.55v5.4l-3.55 3.55H5.
|
|
21
|
+
tip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.75a4.25 4.25 0 0 0-2.5 7.7c.4.3.6.7.6 1.2v.6h3.8v-.6c0-.5.2-.9.6-1.2A4.25 4.25 0 0 0 8 1.75z"/><path d="M6.2 13.6h3.6"/></svg>',
|
|
22
|
+
important: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 3.25a1.5 1.5 0 0 1 1.5-1.5h8.5a1.5 1.5 0 0 1 1.5 1.5v6.5a1.5 1.5 0 0 1-1.5 1.5H7.5l-3 2.75v-2.75h-.75a1.5 1.5 0 0 1-1.5-1.5z"/><path d="M8 4.5v3"/><path d="M8 9.4v.1" stroke-width="2"/></svg>',
|
|
23
|
+
warning: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.13 2.4a1 1 0 0 1 1.74 0l5.6 9.9a1 1 0 0 1-.87 1.5H2.4a1 1 0 0 1-.87-1.5z"/><path d="M8 6.3v3.1"/><path d="M8 11.4v.1" stroke-width="2"/></svg>',
|
|
24
|
+
caution: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.3 1.75h5.4l3.55 3.55v5.4l-3.55 3.55H5.3L1.75 10.7V5.3z"/><path d="M8 4.8v3.6"/><path d="M8 10.6v.1" stroke-width="2"/></svg>',
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
function meaningfulChildren(node) {
|
|
@@ -32,7 +32,7 @@ const meaningful = (children) =>
|
|
|
32
32
|
children.filter((child) => !(child.type === "text" && !child.value.trim()) && child.type !== "break");
|
|
33
33
|
|
|
34
34
|
/** Take the markers out of one parent's inline children; return the marked links. */
|
|
35
|
-
function takeMarkers(parent, source) {
|
|
35
|
+
function takeMarkers(parent, source, sameSite) {
|
|
36
36
|
const marked = [];
|
|
37
37
|
const children = parent.children ?? [];
|
|
38
38
|
for (let index = 0; index < children.length - 1; index += 1) {
|
|
@@ -41,13 +41,14 @@ function takeMarkers(parent, source) {
|
|
|
41
41
|
if (link.type !== "link" || next.type !== "text" || !next.value.startsWith(MARKER)) continue;
|
|
42
42
|
if (!isHttp(link.url) || !writtenAsLink(link, source)) continue;
|
|
43
43
|
next.value = next.value.slice(MARKER.length);
|
|
44
|
-
|
|
44
|
+
// A link back to this site is an ordinary internal link: the marker goes, no card.
|
|
45
|
+
if (!sameSite(link.url)) marked.push(link);
|
|
45
46
|
}
|
|
46
47
|
parent.children = children.filter((child) => !(child.type === "text" && child.value === ""));
|
|
47
48
|
return marked;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function walk(parent, source) {
|
|
51
|
+
function walk(parent, source, sameSite) {
|
|
51
52
|
const children = parent.children ?? [];
|
|
52
53
|
for (let index = 0; index < children.length; index += 1) {
|
|
53
54
|
const node = children[index];
|
|
@@ -55,14 +56,14 @@ function walk(parent, source) {
|
|
|
55
56
|
if (node.type !== "paragraph") {
|
|
56
57
|
if (node.children) {
|
|
57
58
|
// Headings, table cells and the like: drop the marker, no card.
|
|
58
|
-
if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source);
|
|
59
|
-
walk(node, source);
|
|
59
|
+
if (node.type === "heading" || node.type === "tableCell") takeMarkers(node, source, sameSite);
|
|
60
|
+
walk(node, source, sameSite);
|
|
60
61
|
}
|
|
61
62
|
continue;
|
|
62
63
|
}
|
|
63
64
|
// Links nested in emphasis count too.
|
|
64
|
-
const marked = takeMarkers(node, source);
|
|
65
|
-
for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source));
|
|
65
|
+
const marked = takeMarkers(node, source, sameSite);
|
|
66
|
+
for (const child of node.children) if (child.children && child.type !== "link") marked.push(...takeMarkers(child, source, sameSite));
|
|
66
67
|
if (!marked.length) continue;
|
|
67
68
|
const rest = meaningful(node.children);
|
|
68
69
|
if (marked.length === 1 && rest.length === 1 && rest[0] === marked[0]) {
|
|
@@ -75,9 +76,10 @@ function walk(parent, source) {
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
export function remarkLinkCard() {
|
|
79
|
+
export function remarkLinkCard(options = {}) {
|
|
80
|
+
const sameSite = typeof options?.sameSite === "function" ? options.sameSite : () => null;
|
|
79
81
|
return (tree, file) => {
|
|
80
82
|
const source = typeof file?.value === "string" ? file.value : String(file?.value ?? "");
|
|
81
|
-
walk(tree, source);
|
|
83
|
+
walk(tree, source, sameSite);
|
|
82
84
|
};
|
|
83
85
|
}
|
package/app/styles/a2.css
CHANGED
|
@@ -466,15 +466,15 @@ html[data-theme="dark"] .markdown-body img.fav {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
.markdown-body.prose {
|
|
469
|
-
--al-note: #
|
|
470
|
-
--al-note-bg: #
|
|
471
|
-
--al-note-line: rgba(
|
|
469
|
+
--al-note: #2c55b4;
|
|
470
|
+
--al-note-bg: #eef3fc;
|
|
471
|
+
--al-note-line: rgba(44, 85, 180, 0.22);
|
|
472
472
|
--al-tip: #2d6a3e;
|
|
473
473
|
--al-tip-bg: #edf5ef;
|
|
474
474
|
--al-tip-line: rgba(45, 106, 62, 0.22);
|
|
475
|
-
--al-important: #
|
|
476
|
-
--al-important-bg: #
|
|
477
|
-
--al-important-line: rgba(
|
|
475
|
+
--al-important: #6a3fb5;
|
|
476
|
+
--al-important-bg: #f3effa;
|
|
477
|
+
--al-important-line: rgba(106, 63, 181, 0.22);
|
|
478
478
|
--al-warning: #8a5300;
|
|
479
479
|
--al-warning-bg: #fbf3e5;
|
|
480
480
|
--al-warning-line: rgba(176, 108, 0, 0.26);
|
|
@@ -487,15 +487,15 @@ html[data-theme="dark"] .markdown-body img.fav {
|
|
|
487
487
|
:root[data-theme="dark"] .prose,
|
|
488
488
|
html[data-theme="dark"] .markdown-body.prose,
|
|
489
489
|
html[data-theme="dark"] .prose {
|
|
490
|
-
--al-note: #
|
|
491
|
-
--al-note-bg: rgba(
|
|
492
|
-
--al-note-line: rgba(
|
|
490
|
+
--al-note: #93b2f4;
|
|
491
|
+
--al-note-bg: rgba(147, 178, 244, 0.09);
|
|
492
|
+
--al-note-line: rgba(147, 178, 244, 0.28);
|
|
493
493
|
--al-tip: #8fcb9f;
|
|
494
494
|
--al-tip-bg: rgba(143, 203, 159, 0.08);
|
|
495
495
|
--al-tip-line: rgba(143, 203, 159, 0.26);
|
|
496
|
-
--al-important: #
|
|
497
|
-
--al-important-bg:
|
|
498
|
-
--al-important-line: rgba(
|
|
496
|
+
--al-important: #bfa5f0;
|
|
497
|
+
--al-important-bg: rgba(191, 165, 240, 0.09);
|
|
498
|
+
--al-important-line: rgba(191, 165, 240, 0.28);
|
|
499
499
|
--al-warning: #e6b566;
|
|
500
500
|
--al-warning-bg: rgba(230, 181, 102, 0.08);
|
|
501
501
|
--al-warning-line: rgba(230, 181, 102, 0.28);
|
|
@@ -535,17 +535,10 @@ html[data-theme="dark"] .prose {
|
|
|
535
535
|
--al-line: var(--al-tip-line);
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
-
/* IMPORTANT reads in ink: a dark bar on the left and a bold title. */
|
|
539
538
|
.markdown-body .markdown-alert-important {
|
|
540
539
|
--al: var(--al-important);
|
|
541
540
|
--al-bg: var(--al-important-bg);
|
|
542
541
|
--al-line: var(--al-important-line);
|
|
543
|
-
border-left: 3px solid var(--al-important);
|
|
544
|
-
padding-left: 18px;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
.markdown-body .markdown-alert-important .markdown-alert-title {
|
|
548
|
-
font-weight: 700;
|
|
549
542
|
}
|
|
550
543
|
|
|
551
544
|
.markdown-body .markdown-alert-warning {
|
package/app/styles/tokens.css
CHANGED
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { countDemoWords } from "../ui/garden/word-count.mjs";
|
|
|
10
10
|
import { referenceDomain } from "../ui/garden/references.mjs";
|
|
11
11
|
import { linkAssetsForRender } from "./link-assets.mjs";
|
|
12
12
|
import { cardUrls } from "../ui/garden/markdown-links.mjs";
|
|
13
|
+
import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
|
|
13
14
|
import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
|
|
14
15
|
import { coverImageSet, loadSharp } from "./cover-images.mjs";
|
|
15
16
|
|
|
@@ -39,6 +40,9 @@ const siteUrl = (process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "ht
|
|
|
39
40
|
const publicBaseUrl = basePath && !siteUrl.endsWith(basePath)
|
|
40
41
|
? `${siteUrl}${basePath}`
|
|
41
42
|
: siteUrl;
|
|
43
|
+
// Full URLs to this address are internal links: no new tab, no card.
|
|
44
|
+
const siteAddress = configuredSiteAddress(siteConfig, process.env);
|
|
45
|
+
const sameSite = sameSiteMatcher(siteAddress, basePath);
|
|
42
46
|
const siteName = process.env.SITE_NAME ?? siteConfig.name;
|
|
43
47
|
const siteDescription = process.env.SITE_DESCRIPTION ?? siteConfig.description;
|
|
44
48
|
|
|
@@ -630,6 +634,8 @@ export const githubUrl = ${JSON.stringify(`https://github.com/${siteConfig.autho
|
|
|
630
634
|
export const searchIndexUrl = ${JSON.stringify(`${basePath}/search-index.json?v=${contentHash}`)};
|
|
631
635
|
// Every body as plain text, fetched in the background after load; the name changes with the text.
|
|
632
636
|
export const searchFull = ${JSON.stringify({ url: `${basePath}/${fullIndex.fileName}`, hash: fullIndex.hash })};
|
|
637
|
+
// Full URLs to this address render as internal links.
|
|
638
|
+
export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath })};
|
|
633
639
|
`,
|
|
634
640
|
);
|
|
635
641
|
fs.mkdirSync(path.join(root, "public"), { recursive: true });
|
|
@@ -701,7 +707,7 @@ const sitemapEntries = [
|
|
|
701
707
|
// Card URLs in published entries: the only pages the link-card Worker may read.
|
|
702
708
|
fs.writeFileSync(
|
|
703
709
|
path.join(publicRoot, "link-allowlist.json"),
|
|
704
|
-
`${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)))].sort() }, null, 2)}\n`,
|
|
710
|
+
`${JSON.stringify({ urls: [...new Set([...posts, ...columns, ...thoughts].flatMap((entry) => cardUrls(entry.content)).filter((url) => !sameSite(url)))].sort() }, null, 2)}\n`,
|
|
705
711
|
);
|
|
706
712
|
fs.writeFileSync(path.join(publicRoot, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8" ?>
|
|
707
713
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapEntries.map((entry) => `
|
package/scripts/link-assets.mjs
CHANGED
|
@@ -17,7 +17,9 @@ import path from "node:path";
|
|
|
17
17
|
import { pathToFileURL } from "node:url";
|
|
18
18
|
import matter from "gray-matter";
|
|
19
19
|
import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, linkHost } from "./link-meta-core.mjs";
|
|
20
|
+
import { parse as parseYaml } from "yaml";
|
|
20
21
|
import { markdownLinks } from "../ui/garden/markdown-links.mjs";
|
|
22
|
+
import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
|
|
21
23
|
|
|
22
24
|
export const CACHE_FILE = "link-assets.json";
|
|
23
25
|
export const ASSET_DIR = path.join("public", "link-assets");
|
|
@@ -49,15 +51,26 @@ function markdownFiles(directory) {
|
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function readSiteConfig(siteRoot) {
|
|
55
|
+
try {
|
|
56
|
+
return parseYaml(fs.readFileSync(path.join(siteRoot, "site.config.yaml"), "utf8")) ?? {};
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
/** External links across every post: the sites (for favicons) and the URLs. */
|
|
53
|
-
export function collectSiteLinks(siteRoot) {
|
|
63
|
+
export function collectSiteLinks(siteRoot, env = process.env) {
|
|
64
|
+
const config = readSiteConfig(siteRoot);
|
|
65
|
+
// Links back to this site are internal: no favicon, no card.
|
|
66
|
+
const sameSite = sameSiteMatcher(configuredSiteAddress(config, env), env.NEXT_PUBLIC_BASE_PATH ?? "");
|
|
54
67
|
const hosts = new Map();
|
|
55
68
|
const cards = new Set();
|
|
56
69
|
const urls = new Set();
|
|
57
70
|
for (const file of markdownFiles(path.join(siteRoot, "content"))) {
|
|
58
71
|
const { content } = matter(fs.readFileSync(file, "utf8"));
|
|
59
72
|
for (const { url, card } of markdownLinks(content)) {
|
|
60
|
-
if (!isPublicUrl(url)) continue;
|
|
73
|
+
if (!isPublicUrl(url) || sameSite(url)) continue;
|
|
61
74
|
urls.add(url);
|
|
62
75
|
if (card) cards.add(url);
|
|
63
76
|
const host = linkHost(url);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links to the site's own address. A post may write a full URL such as
|
|
3
|
+
* `https://example.com/blog/post/`; it is still an internal link.
|
|
4
|
+
*
|
|
5
|
+
* The site address comes from `url` in site.config.yaml, falling back to the
|
|
6
|
+
* SITE_URL the build already uses. Both http and https count, and so do the
|
|
7
|
+
* host with and without `www.`. Any other subdomain is a different site.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The configured site address, or "" when none is set. */
|
|
11
|
+
export function configuredSiteAddress(config = {}, env = {}) {
|
|
12
|
+
const value = config?.url ?? config?.siteUrl ?? env.SITE_URL ?? env.NEXT_PUBLIC_SITE_URL ?? "";
|
|
13
|
+
return String(value).trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const hostKey = (hostname) => hostname.toLowerCase().replace(/\.$/u, "").replace(/^www\./u, "");
|
|
17
|
+
const normalizeBase = (basePath) => {
|
|
18
|
+
const trimmed = String(basePath ?? "").trim().replace(/\/+$/u, "");
|
|
19
|
+
return !trimmed ? "" : trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns `href => path | null`. The path is root-relative and keeps the base
|
|
24
|
+
* path, query and hash; null means the link points somewhere else.
|
|
25
|
+
*/
|
|
26
|
+
export function sameSiteMatcher(siteAddress, basePath = "") {
|
|
27
|
+
let site;
|
|
28
|
+
try {
|
|
29
|
+
site = siteAddress ? new URL(siteAddress) : null;
|
|
30
|
+
} catch {
|
|
31
|
+
site = null;
|
|
32
|
+
}
|
|
33
|
+
if (!site || !/^https?:$/u.test(site.protocol)) return () => null;
|
|
34
|
+
const key = hostKey(site.hostname);
|
|
35
|
+
const port = site.port;
|
|
36
|
+
// A base path written into the site address counts the same as NEXT_PUBLIC_BASE_PATH.
|
|
37
|
+
const base = normalizeBase(basePath) || normalizeBase(site.pathname);
|
|
38
|
+
return (href) => {
|
|
39
|
+
if (typeof href !== "string" || !/^(https?:)?\/\//iu.test(href.trim())) return null;
|
|
40
|
+
let url;
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(href.trim(), `${site.protocol}//${site.host}/`);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (!/^https?:$/u.test(url.protocol) || url.username || url.password) return null;
|
|
47
|
+
if (hostKey(url.hostname) !== key || url.port !== port) return null;
|
|
48
|
+
const pathname = url.pathname || "/";
|
|
49
|
+
if (base && pathname !== base && !pathname.startsWith(`${base}/`)) return null;
|
|
50
|
+
return `${pathname}${url.search}${url.hash}`;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How an article link renders: a link back to the site becomes its
|
|
56
|
+
* root-relative path and opens in place; other full URLs are external.
|
|
57
|
+
*/
|
|
58
|
+
export function articleLink(href, sameSite) {
|
|
59
|
+
if (!href) return { href, external: false };
|
|
60
|
+
const local = sameSite(href);
|
|
61
|
+
if (local !== null) return { href: local, external: false };
|
|
62
|
+
return { href, external: /^(https?:)?\/\//iu.test(href) };
|
|
63
|
+
}
|