hd-garden 0.5.13 → 0.5.15
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/a2/page.tsx +2 -1
- package/app/about/page.tsx +2 -1
- package/app/columns/[column]/[...path]/page.tsx +2 -1
- package/app/components/ArticleTags.tsx +6 -3
- package/app/components/BlogArchive.tsx +3 -2
- package/app/components/ContentEmptyState.tsx +2 -1
- package/app/components/MarkdownArticle.tsx +40 -3
- package/app/components/SiteHeader.tsx +18 -15
- package/app/components/TagRedirect.tsx +4 -3
- package/app/components/ThoughtCard.tsx +3 -2
- package/app/components/garden/FilePreview.tsx +176 -0
- package/app/components/garden/GardenBlock.tsx +4 -0
- package/app/components/garden/GardenFile.tsx +69 -0
- package/app/components/garden/file-icons.tsx +49 -0
- package/app/garden-modules.d.ts +1 -1
- package/app/layout.tsx +3 -0
- package/app/lib/content.ts +5 -3
- package/app/lib/markdown-plugins.ts +2 -11
- package/app/lib/site-href.ts +9 -0
- package/app/lib/tag-filter.ts +8 -0
- package/app/page.tsx +5 -4
- package/app/rss.xml/route.ts +6 -3
- package/app/sitemap.ts +4 -3
- package/app/styles/a2.css +72 -0
- package/app/styles/shell.css +21 -2
- package/app/tags/page.tsx +3 -2
- package/app/thoughts/[slug]/page.tsx +4 -3
- package/bin/garden.mjs +10 -2
- package/next.config.ts +3 -1
- package/package.json +3 -1
- package/scripts/build-content.mjs +110 -10
- package/scripts/check-post.mjs +45 -0
- package/scripts/file-preview.mjs +138 -0
- package/scripts/file-sizes.mjs +146 -0
- package/scripts/prepare-pages-output.mjs +7 -1
- package/scripts/redirects.mjs +80 -55
- package/templates/garden-blocks.md +11 -0
- package/templates/garden-snippets.plist +9 -0
- package/ui/core/transform.mjs +18 -0
- package/ui/garden/catalog.mjs +15 -1
- package/ui/garden/check.mjs +12 -0
- package/ui/garden/file-block.mjs +232 -0
- package/ui/garden/file-paths.mjs +65 -0
- package/ui/garden/hash-jump.mjs +37 -0
- package/ui/garden/internal-links.mjs +113 -0
- package/ui/garden/markdown-syntax.mjs +12 -0
- package/ui/garden/parse.mjs +1 -1
- package/ui/garden/same-site.mjs +27 -0
- package/ui/garden/site-href.mjs +62 -0
- package/ui/garden/site-nav.mjs +25 -0
- package/ui/garden/snippets.mjs +2 -2
- package/ui/garden/word-count.mjs +5 -0
- package/ui/preview/dist/garden-ui.esm.js +33 -33
- package/ui/preview/dist/garden-ui.iife.js +32 -32
package/app/lib/content.ts
CHANGED
|
@@ -15,24 +15,26 @@ import remarkParse from "remark-parse";
|
|
|
15
15
|
import { unified } from "unified";
|
|
16
16
|
import { visit } from "unist-util-visit";
|
|
17
17
|
import { splitHeadingNumber } from "./heading-number";
|
|
18
|
+
import { siteHref } from "./site-href";
|
|
18
19
|
|
|
19
20
|
export { columns, posts, thoughts };
|
|
20
21
|
export type { ContentEntry, MediaItem };
|
|
21
22
|
|
|
22
|
-
export const postHref = (post: Pick<ContentEntry, "path">) => `/blog/${post.path}
|
|
23
|
+
export const postHref = (post: Pick<ContentEntry, "path">) => siteHref(`/blog/${post.path}`);
|
|
23
24
|
export const columnEntryPath = (entry: Pick<ContentEntry, "column" | "path">) => {
|
|
24
25
|
const prefix = entry.column ? `${entry.column}/` : "";
|
|
25
26
|
return prefix && entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path;
|
|
26
27
|
};
|
|
27
28
|
export const columnHref = (entry: Pick<ContentEntry, "column" | "path">) =>
|
|
28
|
-
`/columns/${entry.column}/${columnEntryPath(entry)}
|
|
29
|
+
siteHref(`/columns/${entry.column}/${columnEntryPath(entry)}`);
|
|
29
30
|
|
|
30
31
|
/** The column a post belongs to (the first one, if it is listed in several). */
|
|
31
32
|
export const columnOfPost = (post: Pick<ContentEntry, "path">) => {
|
|
32
33
|
const entry = columns.find((item) => item.path === post.path && item.column);
|
|
33
34
|
return entry?.column ? { slug: entry.column, title: entry.columnTitle ?? entry.column } : undefined;
|
|
34
35
|
};
|
|
35
|
-
export const columnIndexHref = (slug: string) => `/columns/#${slug}
|
|
36
|
+
export const columnIndexHref = (slug: string) => siteHref(`/columns/#${slug}`);
|
|
37
|
+
export const thoughtHref = (slug: string) => siteHref(`/thoughts/${slug}`);
|
|
36
38
|
|
|
37
39
|
export const formatDate = (value: string, withTime = false) => {
|
|
38
40
|
if (!withTime) return demoDateLabel(value);
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import remarkGfm from "remark-gfm";
|
|
2
|
-
import remarkCjkFriendly from "remark-cjk-friendly";
|
|
3
|
-
import remarkBreaks from "remark-breaks";
|
|
4
|
-
import remarkMath from "remark-math";
|
|
5
1
|
import type { PluggableList } from "unified";
|
|
2
|
+
import { markdownSyntaxPlugins } from "../../ui/garden/markdown-syntax.mjs";
|
|
6
3
|
import { remarkGarden } from "./remark-garden.mjs";
|
|
7
4
|
|
|
8
5
|
// Keep article rendering and heading extraction on the same Markdown syntax.
|
|
9
|
-
export const markdownPlugins: PluggableList = [
|
|
10
|
-
remarkGfm,
|
|
11
|
-
remarkCjkFriendly,
|
|
12
|
-
remarkBreaks,
|
|
13
|
-
remarkMath,
|
|
14
|
-
remarkGarden,
|
|
15
|
-
];
|
|
6
|
+
export const markdownPlugins: PluggableList = [...markdownSyntaxPlugins, remarkGarden];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { siteLinks } from "@garden/site-config";
|
|
2
|
+
import { withTrailingSlash } from "../../ui/garden/site-href.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every address inside the site goes through here. Page paths follow Next's
|
|
6
|
+
* trailingSlash (always on in a static export, TRAILING_SLASH=1 on a server);
|
|
7
|
+
* ?query and #hash come after the path.
|
|
8
|
+
*/
|
|
9
|
+
export const siteHref = (href: string): string => withTrailingSlash(href, siteLinks?.trailingSlash === true);
|
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/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { CoverImage } from "./components/CoverImage";
|
|
|
3
3
|
import { homeListDate } from "./lib/a2-meta";
|
|
4
4
|
import { columns, formatDate, postHref, posts } from "./lib/content";
|
|
5
5
|
import { githubUrl, siteConfig } from "./site.config";
|
|
6
|
+
import { siteHref } from "./lib/site-href";
|
|
6
7
|
|
|
7
8
|
export const metadata = {
|
|
8
9
|
title: { absolute: siteConfig.name },
|
|
@@ -40,7 +41,7 @@ export default function Home() {
|
|
|
40
41
|
<h1>{siteConfig.pages.home.title}</h1>
|
|
41
42
|
<p>{siteConfig.pages.home.subtitle || siteConfig.description}</p>
|
|
42
43
|
<div className="links">
|
|
43
|
-
<Link href="/about">关于我</Link>
|
|
44
|
+
<Link href={siteHref("/about")}>关于我</Link>
|
|
44
45
|
<a href={githubUrl} rel="me">GitHub</a>
|
|
45
46
|
<a href="/rss.xml">RSS 订阅</a>
|
|
46
47
|
</div>
|
|
@@ -71,7 +72,7 @@ export default function Home() {
|
|
|
71
72
|
<section className="sh-section" aria-labelledby="h-recent">
|
|
72
73
|
<div className="sh-sec-head">
|
|
73
74
|
<h2 id="h-recent">最近更新</h2>
|
|
74
|
-
<Link className="sh-more" href="/blog">全部 {posts.length} 篇</Link>
|
|
75
|
+
<Link className="sh-more" href={siteHref("/blog")}>全部 {posts.length} 篇</Link>
|
|
75
76
|
</div>
|
|
76
77
|
<ol className="sh-rows">
|
|
77
78
|
{recent.slice(0, 6).map((post) => (
|
|
@@ -102,7 +103,7 @@ export default function Home() {
|
|
|
102
103
|
<section className="sh-section" aria-labelledby="h-cols">
|
|
103
104
|
<div className="sh-sec-head">
|
|
104
105
|
<h2 id="h-cols">专栏</h2>
|
|
105
|
-
<Link className="sh-more" href="/columns">全部专栏</Link>
|
|
106
|
+
<Link className="sh-more" href={siteHref("/columns")}>全部专栏</Link>
|
|
106
107
|
</div>
|
|
107
108
|
<div className="sh-cols">
|
|
108
109
|
{series.map(([slug, entries]) => {
|
|
@@ -116,7 +117,7 @@ export default function Home() {
|
|
|
116
117
|
) : null}
|
|
117
118
|
</div>
|
|
118
119
|
<div className="body">
|
|
119
|
-
<h3><Link href={`/columns/#${slug}`}>{first.columnTitle}</Link></h3>
|
|
120
|
+
<h3><Link href={siteHref(`/columns/#${slug}`)}>{first.columnTitle}</Link></h3>
|
|
120
121
|
<p className="count">{entries.length} 篇,{first.columnStatus ?? "持续更新"}</p>
|
|
121
122
|
<p>{first.columnDescription ?? first.description}</p>
|
|
122
123
|
</div>
|
package/app/rss.xml/route.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { marked } from "marked";
|
|
2
|
-
import { columnHref, columns, postHref, posts } from "../lib/content";
|
|
2
|
+
import { columnHref, columns, postHref, posts, thoughtHref } from "../lib/content";
|
|
3
3
|
import { getPublishedThoughts } from "../lib/public-thoughts";
|
|
4
4
|
import { configuredSiteUrl, siteDescription, siteName } from "../lib/site";
|
|
5
5
|
import { contentForFeed } from "../../ui/core/transform.mjs";
|
|
6
6
|
import { stripCardMarkers } from "../../ui/garden/markdown-links.mjs";
|
|
7
|
+
import { withTrailingSlash } from "../../ui/garden/site-href.mjs";
|
|
7
8
|
|
|
8
9
|
const escapeXml = (value: string) =>
|
|
9
10
|
value
|
|
@@ -15,6 +16,8 @@ const escapeXml = (value: string) =>
|
|
|
15
16
|
|
|
16
17
|
const cdata = (value: string) => value.replaceAll("]]>", "]]]]><![CDATA[>");
|
|
17
18
|
|
|
19
|
+
// The link follows trailingSlash; the guid keeps this route's 0.5.14 form (no trailing slash),
|
|
20
|
+
// because readers tell items apart by it.
|
|
18
21
|
export async function GET() {
|
|
19
22
|
const origin = configuredSiteUrl();
|
|
20
23
|
const thoughts = getPublishedThoughts();
|
|
@@ -27,7 +30,7 @@ export async function GET() {
|
|
|
27
30
|
...thoughts.map((thought) => ({
|
|
28
31
|
...thought,
|
|
29
32
|
description: stripCardMarkers(thought.content).replace(/\s+/gu, " ").slice(0, 180),
|
|
30
|
-
path:
|
|
33
|
+
path: thoughtHref(thought.slug),
|
|
31
34
|
})),
|
|
32
35
|
].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
|
|
33
36
|
|
|
@@ -45,7 +48,7 @@ export async function GET() {
|
|
|
45
48
|
<item>
|
|
46
49
|
<title>${escapeXml(entry.title)}</title>
|
|
47
50
|
<link>${origin}${entry.path}</link>
|
|
48
|
-
<guid isPermaLink="true">${origin}${entry.path}</guid>
|
|
51
|
+
<guid isPermaLink="true">${origin}${withTrailingSlash(entry.path, false)}</guid>
|
|
49
52
|
<pubDate>${new Date(entry.date).toUTCString()}</pubDate>
|
|
50
53
|
<description>${escapeXml(entry.description ?? "")}</description>
|
|
51
54
|
${(entry.tags ?? [])
|
package/app/sitemap.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { MetadataRoute } from "next";
|
|
2
|
-
import { columnHref, columns, postHref, posts } from "./lib/content";
|
|
2
|
+
import { columnHref, columns, postHref, posts, thoughtHref } from "./lib/content";
|
|
3
3
|
import { absoluteUrl, getSiteUrl } from "./lib/site";
|
|
4
4
|
import { getPublishedThoughts } from "./lib/public-thoughts";
|
|
5
|
+
import { siteHref } from "./lib/site-href";
|
|
5
6
|
|
|
6
7
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
7
8
|
const baseUrl = await getSiteUrl();
|
|
@@ -14,7 +15,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
14
15
|
{ path: "/about", priority: 0.5, changeFrequency: "monthly" as const },
|
|
15
16
|
{ path: "/garden", priority: 0.4, changeFrequency: "monthly" as const },
|
|
16
17
|
].map((route) => ({
|
|
17
|
-
url: absoluteUrl(route.path, baseUrl),
|
|
18
|
+
url: absoluteUrl(siteHref(route.path), baseUrl),
|
|
18
19
|
changeFrequency: route.changeFrequency,
|
|
19
20
|
priority: route.priority,
|
|
20
21
|
}));
|
|
@@ -37,7 +38,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
37
38
|
priority: 0.75,
|
|
38
39
|
})),
|
|
39
40
|
...thoughts.map((thought) => ({
|
|
40
|
-
url: absoluteUrl(
|
|
41
|
+
url: absoluteUrl(thoughtHref(thought.slug), baseUrl),
|
|
41
42
|
lastModified: new Date(thought.updated ?? thought.date),
|
|
42
43
|
changeFrequency: "monthly" as const,
|
|
43
44
|
priority: 0.6,
|
package/app/styles/a2.css
CHANGED
|
@@ -1567,3 +1567,75 @@ html[data-theme="dark"] .g-quiz-out.no { color: #f29aab; }
|
|
|
1567
1567
|
@media (max-width: 640px) {
|
|
1568
1568
|
.g-range { grid-template-columns: 84px minmax(0, 1fr) 54px; gap: 8px; }
|
|
1569
1569
|
}
|
|
1570
|
+
|
|
1571
|
+
/* ---- garden file: a download card, a group of rows, a preview drawer ---- */
|
|
1572
|
+
.gf { margin: 24px 0; border: 1px solid var(--rule); border-radius: 12px; background: var(--card); font-family: var(--sans); }
|
|
1573
|
+
.gf-title { padding: 14px 16px 0; font-size: 14px; font-weight: 600; line-height: 1.4; color: var(--muted); }
|
|
1574
|
+
.gf-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
|
|
1575
|
+
.gf-row + .gf-row { border-top: 1px solid var(--rule); }
|
|
1576
|
+
.gf-ico { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 8px; background: var(--chip); color: var(--soft); }
|
|
1577
|
+
.gf-i { flex: none; width: 18px; height: 18px; }
|
|
1578
|
+
.gf-main { flex: 1; min-width: 0; }
|
|
1579
|
+
.gf-name { overflow: hidden; font-size: 15px; font-weight: 600; line-height: 1.45; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; }
|
|
1580
|
+
.gf-desc { margin-top: 2px; font-size: 14px; line-height: 1.55; color: var(--soft); }
|
|
1581
|
+
.gf-meta { margin-top: 2px; font-size: 13px; line-height: 1.4; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
1582
|
+
.gf-act { display: flex; flex: none; gap: 6px; }
|
|
1583
|
+
.gf-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; padding: 0 12px; border: 0; border-radius: 999px; background: transparent; color: var(--soft); font: 500 14px/1 var(--sans); white-space: nowrap; cursor: pointer; transition: background-color .15s ease, color .15s ease; }
|
|
1584
|
+
.markdown-body a.gf-btn, a.gf-btn { color: var(--soft); text-decoration: none; }
|
|
1585
|
+
.gf-btn:hover, .markdown-body a.gf-btn:hover { background: var(--tint); color: var(--ink); text-decoration: none; }
|
|
1586
|
+
.gf-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
|
1587
|
+
.gf-btn.icon { width: 34px; padding: 0; }
|
|
1588
|
+
.gf-btn.pri, .markdown-body a.gf-btn.pri { background: var(--ink); color: var(--paper); }
|
|
1589
|
+
.gf-btn.pri:hover, .markdown-body a.gf-btn.pri:hover { background: var(--ink-soft); color: var(--paper); }
|
|
1590
|
+
|
|
1591
|
+
/* The drawer: right side on wide screens, the whole screen on phones. */
|
|
1592
|
+
.gf-layer { position: fixed; inset: 0; z-index: 95; }
|
|
1593
|
+
.gf-scrim { position: absolute; inset: 0; background: rgb(20 20 19 / 22%); animation: gf-fade .16s ease-out; }
|
|
1594
|
+
html[data-theme="dark"] .gf-scrim { background: rgb(0 0 0 / 45%); }
|
|
1595
|
+
.gf-drawer { position: absolute; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(600px, 46vw); border-left: 1px solid var(--rule); background: var(--paper); box-shadow: -12px 0 32px rgb(0 0 0 / 8%); font-family: var(--sans); animation: gf-slide .2s ease-out; }
|
|
1596
|
+
.gf-dh { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px 12px 12px 20px; border-bottom: 1px solid var(--rule); }
|
|
1597
|
+
.gf-dh .gf-act { gap: 2px; }
|
|
1598
|
+
.gf-db { flex: 1; min-height: 0; overflow: auto; padding: 28px 32px 40px; font-size: 15px; line-height: 1.75; overscroll-behavior: contain; }
|
|
1599
|
+
.gf-db-frame { padding: 0; overflow: hidden; background: var(--card); }
|
|
1600
|
+
.gf-frame { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
1601
|
+
.gf-drawer[data-preview="pdf"] .gf-frame { background: var(--paper-deep); }
|
|
1602
|
+
.gf-db .gf-doc { max-width: none; margin: 0; font-size: 15px; line-height: 1.75; }
|
|
1603
|
+
.gf-db .gf-doc > :first-child { margin-top: 0; }
|
|
1604
|
+
@keyframes gf-fade { from { opacity: 0; } }
|
|
1605
|
+
@keyframes gf-slide { from { transform: translateX(24px); opacity: 0; } }
|
|
1606
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1607
|
+
.gf-scrim, .gf-drawer { animation: none; }
|
|
1608
|
+
.gf-btn { transition: none; }
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
@media (max-width: 600px) {
|
|
1612
|
+
.gf-row { flex-wrap: wrap; align-items: flex-start; gap: 12px; padding: 14px; }
|
|
1613
|
+
.gf-main { flex: 1 1 calc(100% - 50px); }
|
|
1614
|
+
.gf-act { width: 100%; padding-left: 48px; }
|
|
1615
|
+
.gf-btn { height: 44px; padding: 0 14px; }
|
|
1616
|
+
.gf-btn.icon { width: 44px; padding: 0; }
|
|
1617
|
+
.gf.group .gf-row { flex-wrap: nowrap; align-items: center; }
|
|
1618
|
+
.gf.group .gf-main { flex: 1 1 auto; }
|
|
1619
|
+
.gf.group .gf-act { width: auto; gap: 0; padding: 0; }
|
|
1620
|
+
.gf.group .gf-btn { width: 44px; padding: 0; }
|
|
1621
|
+
.gf.group .gf-lb { display: none; }
|
|
1622
|
+
.gf-drawer { width: 100%; border: 0; box-shadow: none; }
|
|
1623
|
+
.gf-dh { min-height: 56px; padding: 6px 6px 6px 16px; }
|
|
1624
|
+
.gf-dh .gf-act { width: auto; padding: 0; gap: 0; }
|
|
1625
|
+
.gf-db { padding: 20px 20px 40px; }
|
|
1626
|
+
.gf-db-frame { padding: 0; }
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/* While the prerendered Markdown loads, or when it cannot. (Without scripts 预览 is a plain link to it.) */
|
|
1630
|
+
.gf-state { display: grid; justify-items: start; gap: 12px; padding: 8px 0; font-size: 15px; line-height: 1.6; color: var(--muted); }
|
|
1631
|
+
.gf-state p { margin: 0; color: var(--soft); }
|
|
1632
|
+
.gf-state-act { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
1633
|
+
.gf-state-act .gf-btn { border: 1px solid var(--rule); }
|
|
1634
|
+
/* A previewed Markdown file reads as a document: its own title, a smaller scale than the post. */
|
|
1635
|
+
.gf-db .gf-doc h1 { margin: 0 0 12px; font-size: 22px; font-weight: 600; line-height: 1.4; color: var(--ink); }
|
|
1636
|
+
.gf-db .gf-doc h2 { margin: 28px 0 10px; font-size: 19px; line-height: 1.4; }
|
|
1637
|
+
.gf-db .gf-doc h3 { margin: 24px 0 8px; font-size: 17px; line-height: 1.4; }
|
|
1638
|
+
.gf-db .gf-doc h4 { margin: 20px 0 6px; font-size: 15px; line-height: 1.5; }
|
|
1639
|
+
.gf-db .gf-doc p, .gf-db .gf-doc li { font-size: 15px; line-height: 1.75; }
|
|
1640
|
+
.gf-db .gf-doc p { margin: 0 0 14px; }
|
|
1641
|
+
.gf-db .gf-doc ul, .gf-db .gf-doc ol { margin: 0 0 14px; }
|
package/app/styles/shell.css
CHANGED
|
@@ -65,10 +65,11 @@ 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}
|
|
69
|
+
/* Fragment targets for the no-JS menu: fixed, so jumping to them never scrolls the page. */
|
|
70
|
+
.sh-menu-target{position:fixed;top:0;left:0;width:0;height:0;overflow:hidden;pointer-events:none}
|
|
68
71
|
.i-sun,html[data-theme="dark"] .i-moon{display:none}
|
|
69
72
|
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
73
|
.sh-menu{display:none}
|
|
73
74
|
|
|
74
75
|
/* ---------- page frame ---------- */
|
|
@@ -304,6 +305,24 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
|
|
|
304
305
|
.sh-pal-foot{display:none}
|
|
305
306
|
.sh-pal-input{font-size:17px;height:56px}
|
|
306
307
|
}
|
|
308
|
+
/* No JavaScript: the theme cannot be switched, the page follows the system (build/dark-fallback-postcss.mjs).
|
|
309
|
+
Keep this after every rule that shows the toggles (the 820px block above sets .sh-menu-theme to flex);
|
|
310
|
+
tests/header-nojs.test.mjs checks the order in the built CSS. */
|
|
311
|
+
@media (scripting:none){.sh-theme,.sh-menu-theme{display:none}}
|
|
312
|
+
/* Search needs JavaScript too: without it the desktop 「搜索」 button would do nothing. */
|
|
313
|
+
@media (scripting:none){.sh-search{display:none}}
|
|
314
|
+
/* No JavaScript: the ☰ button cannot open the menu, so a ☰ link in the same place opens it by targeting
|
|
315
|
+
#sh-menu-on, and ✕ closes it by targeting #sh-menu-off. Both are fixed anchors outside the sticky header,
|
|
316
|
+
as the TOC drawer's are, so neither moves the page. With JavaScript none of this applies. */
|
|
317
|
+
@media (max-width:820px) and (scripting:none){
|
|
318
|
+
.sh-menu-btn{display:none}
|
|
319
|
+
/* Search needs JavaScript (the index loads on demand); without it the button would do nothing. */
|
|
320
|
+
.sh-search-m{display:none}
|
|
321
|
+
.sh-menu-nojs{display:inline-grid;width:44px;height:44px;text-decoration:none}
|
|
322
|
+
body:has(#sh-menu-on:target) .sh-menu{opacity:1;transform:none;visibility:visible;transition:opacity .18s ease,transform .18s var(--ease)}
|
|
323
|
+
body:has(#sh-menu-on:target) .sh-menu-nojs{display:none}
|
|
324
|
+
body:has(#sh-menu-on:target) .sh-menu-nojs-close{display:inline-grid;width:44px;height:44px;text-decoration:none}
|
|
325
|
+
}
|
|
307
326
|
@media (prefers-reduced-motion:reduce){
|
|
308
327
|
*,*::before,*::after{animation:none!important;transition:none!important}
|
|
309
328
|
html{scroll-behavior:auto!important}
|
package/app/tags/page.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { Suspense } from "react";
|
|
3
3
|
import { posts } from "../lib/content";
|
|
4
|
-
import {
|
|
4
|
+
import { siteHref } from "../lib/site-href";
|
|
5
|
+
import { articleTagHref, tagCounts } from "../lib/tag-filter";
|
|
5
6
|
import { TagRedirect } from "../components/TagRedirect";
|
|
6
7
|
import { siteDocumentTitle } from "../site.config";
|
|
7
8
|
|
|
@@ -25,7 +26,7 @@ export default function TagsPage() {
|
|
|
25
26
|
<ul className="sh-tagchips sh-tagindex" aria-label="全部标签">
|
|
26
27
|
{tags.map(([tag, count]) => (
|
|
27
28
|
<li key={tag}>
|
|
28
|
-
<Link className="sh-tagchip" href={
|
|
29
|
+
<Link className="sh-tagchip" href={siteHref(articleTagHref(tag))}>
|
|
29
30
|
#{tag}<span className="n num">{count}</span>
|
|
30
31
|
</Link>
|
|
31
32
|
</li>
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getSiteUrl,
|
|
13
13
|
webmentionApiEndpoint,
|
|
14
14
|
} from "../../lib/site";
|
|
15
|
+
import { siteHref } from "../../lib/site-href";
|
|
15
16
|
|
|
16
17
|
export function generateStaticParams() {
|
|
17
18
|
return thoughts.map((thought) => ({ slug: thought.slug }));
|
|
@@ -26,7 +27,7 @@ export async function generateMetadata({
|
|
|
26
27
|
const thought = findPublishedThought(slug);
|
|
27
28
|
if (!thought) return {};
|
|
28
29
|
const baseUrl = await getSiteUrl();
|
|
29
|
-
const pathname = `/thoughts/${slug}
|
|
30
|
+
const pathname = siteHref(`/thoughts/${slug}`);
|
|
30
31
|
const images = Array.isArray(thought.media)
|
|
31
32
|
? thought.media.filter((item) => item.type === "image").slice(0, 4)
|
|
32
33
|
: [];
|
|
@@ -60,11 +61,11 @@ export default async function ThoughtPage({
|
|
|
60
61
|
const thought = findPublishedThought(slug);
|
|
61
62
|
if (!thought) notFound();
|
|
62
63
|
const baseUrl = await getSiteUrl();
|
|
63
|
-
const pathname = `/thoughts/${slug}
|
|
64
|
+
const pathname = siteHref(`/thoughts/${slug}`);
|
|
64
65
|
|
|
65
66
|
return (
|
|
66
67
|
<main className="page-shell thought-detail-page">
|
|
67
|
-
<Link href="/thoughts" className="back-link">
|
|
68
|
+
<Link href={siteHref("/thoughts")} className="back-link">
|
|
68
69
|
<ArrowLeft size={16} />
|
|
69
70
|
返回随想
|
|
70
71
|
</Link>
|
package/bin/garden.mjs
CHANGED
|
@@ -32,7 +32,8 @@ function runNode(script, scriptArgs = [], { cwd = siteRoot, env } = {}) {
|
|
|
32
32
|
env: env ?? siteEnv(),
|
|
33
33
|
stdio: "inherit",
|
|
34
34
|
});
|
|
35
|
-
|
|
35
|
+
// A non-zero exit or a signal (status null) stops the command with a failure.
|
|
36
|
+
if (result.status !== 0) process.exit(result.status || 1);
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
function resolveVinext() {
|
|
@@ -278,7 +279,8 @@ function runVinext(vinextArgs, { copyDist = false } = {}) {
|
|
|
278
279
|
stdio: "inherit",
|
|
279
280
|
});
|
|
280
281
|
if (copyDist) publishDist(cwd);
|
|
281
|
-
|
|
282
|
+
// A non-zero exit or a signal (status null) stops the command with a failure.
|
|
283
|
+
if (result.status !== 0) process.exit(result.status || 1);
|
|
282
284
|
}
|
|
283
285
|
|
|
284
286
|
function copyTemplate(from, to) {
|
|
@@ -356,6 +358,7 @@ function help() {
|
|
|
356
358
|
garden embed 拉取外链摘要
|
|
357
359
|
garden links 重新拉取外链图标(build 时会自动做)
|
|
358
360
|
garden images 读取新的远程图片尺寸,写入 image-sizes.json(build 时会自动做)
|
|
361
|
+
garden files 读取 garden file 里远程文件的大小,写入 file-sizes.json(build 时会自动做)
|
|
359
362
|
garden init [目录] 创建只含稿和配置的新站点
|
|
360
363
|
garden check <文章.md> 检查一篇博文的硬性结构
|
|
361
364
|
garden ui <子命令> Intelligent UI:scan、check、apply、prompt、schema、catalog
|
|
@@ -385,6 +388,8 @@ switch (command) {
|
|
|
385
388
|
if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/link-assets.mjs");
|
|
386
389
|
// Remote image sizes: only new images are probed; the rest come from image-sizes.json.
|
|
387
390
|
if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/image-sizes.mjs");
|
|
391
|
+
// Sizes of remote files in garden file blocks; failures fall back to file-sizes.json.
|
|
392
|
+
if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/file-sizes.mjs");
|
|
388
393
|
runVinext(["build", ...rest], { copyDist: true });
|
|
389
394
|
// Pages for old addresses listed under redirects in site.config.yaml (static export only).
|
|
390
395
|
if (process.env.STATIC_EXPORT === "1") runNode("scripts/redirects.mjs");
|
|
@@ -395,6 +400,9 @@ switch (command) {
|
|
|
395
400
|
case "images":
|
|
396
401
|
runNode("scripts/image-sizes.mjs");
|
|
397
402
|
break;
|
|
403
|
+
case "files":
|
|
404
|
+
runNode("scripts/file-sizes.mjs");
|
|
405
|
+
break;
|
|
398
406
|
case "start":
|
|
399
407
|
runVinext(["start", ...rest]);
|
|
400
408
|
break;
|
package/next.config.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
|
+
import { trailingSlashEnabled } from "./ui/garden/site-href.mjs";
|
|
2
3
|
|
|
3
4
|
const nextConfig: NextConfig = {
|
|
4
5
|
...(process.env.STATIC_EXPORT === "1"
|
|
5
6
|
? {
|
|
6
7
|
output: "export",
|
|
7
|
-
trailingSlash: true,
|
|
8
8
|
images: { unoptimized: true },
|
|
9
9
|
}
|
|
10
10
|
: {}),
|
|
11
|
+
// Always on in a static export; a server build opts in with TRAILING_SLASH=1. Links follow it (siteHref).
|
|
12
|
+
trailingSlash: trailingSlashEnabled(process.env),
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export default nextConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hd-garden",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.15",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
|
|
6
6
|
"license": "MIT",
|
|
@@ -102,11 +102,13 @@
|
|
|
102
102
|
"rehype-katex": "^7.0.1",
|
|
103
103
|
"rehype-raw": "^7.0.0",
|
|
104
104
|
"rehype-slug": "^6.0.0",
|
|
105
|
+
"rehype-stringify": "^10.0.1",
|
|
105
106
|
"remark-breaks": "^4.0.0",
|
|
106
107
|
"remark-cjk-friendly": "^2.3.1",
|
|
107
108
|
"remark-gfm": "^4.0.1",
|
|
108
109
|
"remark-math": "^6.0.0",
|
|
109
110
|
"remark-parse": "^11.0.0",
|
|
111
|
+
"remark-rehype": "^11.1.2",
|
|
110
112
|
"tailwindcss": "4.2.1",
|
|
111
113
|
"typescript": "5.9.3",
|
|
112
114
|
"unified": "^11.0.5",
|
|
@@ -15,6 +15,20 @@ import { resolveBrand } from "./brand.mjs";
|
|
|
15
15
|
import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
|
|
16
16
|
import { buildFullIndex, buildSearchIndex } from "../ui/garden/search-index.mjs";
|
|
17
17
|
import { coverImageSet, loadSharp } from "./cover-images.mjs";
|
|
18
|
+
import { readFileSizeCache } from "./file-sizes.mjs";
|
|
19
|
+
import { previewDocument, previewFileName, renderFilePreview } from "./file-preview.mjs";
|
|
20
|
+
import { parseGardenBlock, parseGardenInfo, scanFences } from "../ui/garden/parse.mjs";
|
|
21
|
+
import {
|
|
22
|
+
fileIcon,
|
|
23
|
+
fileName,
|
|
24
|
+
formatLabel,
|
|
25
|
+
isRemoteSrc,
|
|
26
|
+
previewKind,
|
|
27
|
+
maskHtmlComments,
|
|
28
|
+
readFileProps,
|
|
29
|
+
} from "../ui/garden/file-block.mjs";
|
|
30
|
+
import { resolveRepoFile } from "../ui/garden/file-paths.mjs";
|
|
31
|
+
import { trailingSlashEnabled, withTrailingSlash } from "../ui/garden/site-href.mjs";
|
|
18
32
|
|
|
19
33
|
function resolveSiteRoot() {
|
|
20
34
|
if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
|
|
@@ -266,6 +280,80 @@ function rewriteWidgetFences(content, markdownPath, sourcePath) {
|
|
|
266
280
|
);
|
|
267
281
|
}
|
|
268
282
|
|
|
283
|
+
const remoteFileSizes = readFileSizeCache(root).files;
|
|
284
|
+
|
|
285
|
+
/** One file of a `garden file` fence, resolved for the page. */
|
|
286
|
+
function resolveFileEntry(file, markdownPath, sourcePath) {
|
|
287
|
+
const src = file.src;
|
|
288
|
+
const name = fileName(src);
|
|
289
|
+
const entry = {
|
|
290
|
+
name,
|
|
291
|
+
title: file.title || name,
|
|
292
|
+
...(file.note ? { note: file.note } : {}),
|
|
293
|
+
format: formatLabel(src),
|
|
294
|
+
icon: fileIcon(src),
|
|
295
|
+
};
|
|
296
|
+
if (isRemoteSrc(src)) {
|
|
297
|
+
const size = remoteFileSizes[src]?.size;
|
|
298
|
+
return { ...entry, url: src, remote: true, preview: "", ...(size > 0 ? { size } : {}) };
|
|
299
|
+
}
|
|
300
|
+
if (src.startsWith("/")) {
|
|
301
|
+
// A site path: served as is, download only.
|
|
302
|
+
const file = path.join(root, "public", src.split(/[?#]/u, 1)[0]);
|
|
303
|
+
const size = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.statSync(file).size : 0;
|
|
304
|
+
return { ...entry, url: `${basePath}${src}`, remote: false, preview: "", ...(size > 0 ? { size } : {}) };
|
|
305
|
+
}
|
|
306
|
+
const resolved = resolveRepoFile(src, markdownPath, contentRoot);
|
|
307
|
+
if (resolved.error) fail(sourcePath, `garden file:${resolved.error}`);
|
|
308
|
+
const missingMessage = `garden file 找不到 ${src};src 相对这篇 Markdown 所在的目录`;
|
|
309
|
+
if (!fs.existsSync(resolved.path) || !fs.statSync(resolved.path).isFile()) fail(sourcePath, missingMessage);
|
|
310
|
+
const url = copyLocalAsset(src, markdownPath, sourcePath, { missingMessage });
|
|
311
|
+
const preview = previewKind(src, { remote: false });
|
|
312
|
+
const next = { ...entry, url, size: fs.statSync(resolved.path).size, remote: false, preview };
|
|
313
|
+
if (preview === "md") {
|
|
314
|
+
// Rendered now into its own file next to the copy; the page only links to it and the
|
|
315
|
+
// preview fetches it on demand. Images next to the Markdown file are copied too.
|
|
316
|
+
const { content } = matter(fs.readFileSync(resolved.path, "utf8"));
|
|
317
|
+
const html = renderFilePreview(rewriteMarkdownAssets(content.trim(), resolved.path, sourcePath));
|
|
318
|
+
const { destination } = assetFiles.get(url.split(/[?#]/u, 1)[0]);
|
|
319
|
+
fs.writeFileSync(previewFileName(destination), previewDocument(html, { title: entry.title }));
|
|
320
|
+
next.previewSrc = previewFileName(url.split(/[?#]/u, 1)[0]);
|
|
321
|
+
}
|
|
322
|
+
return next;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* `garden file` fences: resolve every src next to the Markdown file, copy repo
|
|
327
|
+
* files to the public folder, and replace the YAML with `resolved:` data the
|
|
328
|
+
* page renders without reading the file system.
|
|
329
|
+
*/
|
|
330
|
+
function rewriteFileFences(content, markdownPath, sourcePath) {
|
|
331
|
+
const fences = scanFences(maskHtmlComments(content)).filter((fence) => parseGardenInfo(fence.info)?.name === "file" && !fence.unclosed);
|
|
332
|
+
if (!fences.length) return content;
|
|
333
|
+
const lines = content.split("\n");
|
|
334
|
+
for (const fence of [...fences].reverse()) {
|
|
335
|
+
const block = parseGardenBlock(fence.info, fence.body);
|
|
336
|
+
if (block?.error) fail(sourcePath, `第 ${fence.line} 行 garden file:${block.error.message}`);
|
|
337
|
+
if (block.props.resolved) fail(sourcePath, `第 ${fence.line} 行 garden file:resolved 由构建生成,不要手写`);
|
|
338
|
+
const read = readFileProps(block.props);
|
|
339
|
+
if (read.errors.length) fail(sourcePath, `第 ${fence.line} 行 garden file:${read.errors.join(";")}`);
|
|
340
|
+
const resolved = {
|
|
341
|
+
layout: read.layout,
|
|
342
|
+
...(read.title ? { title: read.title } : {}),
|
|
343
|
+
files: read.files.map((file) => resolveFileEntry(file, markdownPath, sourcePath)),
|
|
344
|
+
};
|
|
345
|
+
const indent = " ".repeat(fence.indent);
|
|
346
|
+
lines.splice(
|
|
347
|
+
fence.line - 1,
|
|
348
|
+
fence.endLine - fence.line + 1,
|
|
349
|
+
`${indent}${fence.marker}${fence.info}`,
|
|
350
|
+
`${indent}resolved: ${JSON.stringify(resolved)}`,
|
|
351
|
+
`${indent}${fence.marker}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return lines.join("\n");
|
|
355
|
+
}
|
|
356
|
+
|
|
269
357
|
function rewriteUiFences(content, markdownPath, sourcePath, lineOffset) {
|
|
270
358
|
const { content: transformed, errors } = transformUiContent(content, {
|
|
271
359
|
mode: uiMode,
|
|
@@ -392,9 +480,13 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath, lin
|
|
|
392
480
|
? copyLocalAsset(data.poster, markdownPath, sourcePath)
|
|
393
481
|
: undefined,
|
|
394
482
|
media: normalizeMedia(data.media, markdownPath, sourcePath),
|
|
395
|
-
content:
|
|
396
|
-
|
|
397
|
-
|
|
483
|
+
content: rewriteFileFences(
|
|
484
|
+
rewriteWidgetFences(
|
|
485
|
+
rewriteMarkdownAssets(
|
|
486
|
+
rewriteUiFences(content.trim(), markdownPath, sourcePath, lineOffset + leadingLines(content)),
|
|
487
|
+
markdownPath,
|
|
488
|
+
sourcePath,
|
|
489
|
+
),
|
|
398
490
|
markdownPath,
|
|
399
491
|
sourcePath,
|
|
400
492
|
),
|
|
@@ -411,7 +503,9 @@ function readMarkdownTree(directory, kind) {
|
|
|
411
503
|
|
|
412
504
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
413
505
|
const fullPath = path.join(directory, entry.name);
|
|
414
|
-
|
|
506
|
+
// `post.md` + `post/` is the post's own folder of files (garden html, garden file):
|
|
507
|
+
// Markdown inside it is a file to download or preview, not another post.
|
|
508
|
+
if (entry.isDirectory()) return fs.existsSync(`${fullPath}.md`) ? [] : readMarkdownTree(fullPath, kind);
|
|
415
509
|
if (!entry.name.endsWith(".md")) return [];
|
|
416
510
|
|
|
417
511
|
const raw = fs.readFileSync(fullPath, "utf8");
|
|
@@ -674,7 +768,7 @@ export const searchFull = ${JSON.stringify({ url: `${basePath}/${fullIndex.fileN
|
|
|
674
768
|
// Header logo (inlined SVG) and icon URLs, from brand in site.config.yaml.
|
|
675
769
|
export const brand = ${JSON.stringify(resolveBrand(root, siteConfig, basePath, (line) => console.warn(line)))};
|
|
676
770
|
// Full URLs to this address render as internal links.
|
|
677
|
-
export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath })};
|
|
771
|
+
export const siteLinks = ${JSON.stringify({ address: siteAddress, basePath, trailingSlash: trailingSlashEnabled(process.env) })};
|
|
678
772
|
`,
|
|
679
773
|
);
|
|
680
774
|
fs.mkdirSync(path.join(root, "public"), { recursive: true });
|
|
@@ -696,9 +790,15 @@ const publicRoot = path.join(root, "public");
|
|
|
696
790
|
const columnEntryPath = (entry) => entry.path.startsWith(`${entry.column}/`)
|
|
697
791
|
? entry.path.slice(entry.column.length + 1)
|
|
698
792
|
: entry.path;
|
|
793
|
+
// Page addresses in the feeds and the sitemap follow trailingSlash like every site link.
|
|
794
|
+
const pageAddress = (pathname) => withTrailingSlash(pathname, trailingSlashEnabled(process.env));
|
|
795
|
+
// A feed item's guid is how readers tell items apart: it keeps the form it has had since 0.5.14
|
|
796
|
+
// (`/blog/<path>/`, `/thoughts/<slug>/`) whatever the link looks like, or every reader would show
|
|
797
|
+
// the whole feed as new again.
|
|
798
|
+
const feedEntry = (entry, pathname) => ({ ...entry, path: pageAddress(pathname), guidPath: pathname });
|
|
699
799
|
const feedEntries = [
|
|
700
|
-
...posts.map((entry) => (
|
|
701
|
-
...thoughts.map((entry) => (
|
|
800
|
+
...posts.map((entry) => feedEntry(entry, `/blog/${entry.path}/`)),
|
|
801
|
+
...thoughts.map((entry) => feedEntry(entry, `/thoughts/${entry.slug}/`)),
|
|
702
802
|
].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
|
|
703
803
|
|
|
704
804
|
const feedLastBuildDate = (entries) => new Date(entries.reduce(
|
|
@@ -711,7 +811,7 @@ function writeRssFeed(fileName, entries) {
|
|
|
711
811
|
<item>
|
|
712
812
|
<title>${escapeXml(entry.title)}</title>
|
|
713
813
|
<link>${escapeXml(`${publicBaseUrl}${entry.path}`)}</link>
|
|
714
|
-
<guid isPermaLink="true">${escapeXml(`${publicBaseUrl}${entry.
|
|
814
|
+
<guid isPermaLink="true">${escapeXml(`${publicBaseUrl}${entry.guidPath}`)}</guid>
|
|
715
815
|
<pubDate>${new Date(entry.date).toUTCString()}</pubDate>
|
|
716
816
|
<description>${escapeXml(entry.description ?? contentToSearchText(entry.content).replace(/\s+/gu, " ").trim().slice(0, 180))}</description>
|
|
717
817
|
${(entry.tags ?? []).map((tag) => `<category>${escapeXml(tag)}</category>`).join("")}
|
|
@@ -735,14 +835,14 @@ function writeRssFeed(fileName, entries) {
|
|
|
735
835
|
writeRssFeed("rss.xml", feedEntries);
|
|
736
836
|
writeRssFeed(
|
|
737
837
|
"posts.xml",
|
|
738
|
-
posts.map((entry) => (
|
|
838
|
+
posts.map((entry) => feedEntry(entry, `/blog/${entry.path}/`)),
|
|
739
839
|
);
|
|
740
840
|
const sitemapEntries = [
|
|
741
841
|
"/", "/blog/", "/thoughts/", "/columns/", "/about/",
|
|
742
842
|
...posts.map((entry) => `/blog/${entry.path}/`),
|
|
743
843
|
...columns.map((entry) => `/columns/${entry.column}/${columnEntryPath(entry)}/`),
|
|
744
844
|
...thoughts.map((entry) => `/thoughts/${entry.slug}/`),
|
|
745
|
-
];
|
|
845
|
+
].map(pageAddress);
|
|
746
846
|
// Card URLs in published entries: the only pages the link-card Worker may read.
|
|
747
847
|
fs.writeFileSync(
|
|
748
848
|
path.join(publicRoot, "link-allowlist.json"),
|