hd-garden 0.5.0 → 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.
Files changed (44) hide show
  1. package/app/blog/[...path]/page.tsx +2 -2
  2. package/app/blog/page.tsx +5 -2
  3. package/app/columns/[column]/[...path]/page.tsx +2 -6
  4. package/app/components/ArticleReferences.tsx +31 -31
  5. package/app/components/BlogArchive.tsx +20 -28
  6. package/app/components/CoverImage.tsx +34 -0
  7. package/app/components/LinkCardRefresh.tsx +44 -0
  8. package/app/components/MarkdownArticle.tsx +94 -22
  9. package/app/components/SiteHeader.tsx +104 -28
  10. package/app/garden-modules.d.ts +20 -1
  11. package/app/globals.css +96 -41
  12. package/app/layout.tsx +1 -7
  13. package/app/lib/archive.ts +43 -0
  14. package/app/lib/content.ts +5 -39
  15. package/app/lib/generated-content.ts +1 -1
  16. package/app/lib/heading-number.ts +9 -0
  17. package/app/lib/remark-a2.mjs +4 -4
  18. package/app/lib/remark-link-card.mjs +85 -0
  19. package/app/lib/tag-filter.ts +52 -15
  20. package/app/page.tsx +3 -3
  21. package/app/rss.xml/route.ts +2 -1
  22. package/app/styles/a2.css +56 -12
  23. package/app/styles/tokens.css +1 -1
  24. package/bin/garden.mjs +6 -0
  25. package/package.json +1 -1
  26. package/scripts/build-content.mjs +53 -6
  27. package/scripts/cover-images.mjs +69 -0
  28. package/scripts/link-assets.mjs +284 -0
  29. package/scripts/link-meta-core.mjs +321 -0
  30. package/templates/site/site.config.yaml +2 -0
  31. package/ui/core/transform.mjs +4 -3
  32. package/ui/garden/card-refresh.mjs +92 -0
  33. package/ui/garden/markdown-links.mjs +84 -0
  34. package/ui/garden/references.mjs +21 -0
  35. package/ui/garden/same-site.mjs +63 -0
  36. package/ui/garden/search-full.mjs +131 -0
  37. package/ui/garden/search-index.mjs +102 -0
  38. package/ui/garden/search-match.mjs +96 -0
  39. package/ui/preview/dist/garden-ui.esm.js +3 -3
  40. package/ui/preview/dist/garden-ui.iife.js +2 -2
  41. package/worker/og/README.md +59 -0
  42. package/worker/og/handler.mjs +147 -0
  43. package/worker/og/index.mjs +61 -0
  44. package/worker/og/wrangler.toml +18 -0
@@ -0,0 +1,59 @@
1
+ # 链接卡片刷新(可选)
2
+
3
+ 文章里的链接卡片在构建时就已经完整:标题、简介和本地配图都写进了静态页面,不开 JavaScript 也能看。这个 Worker 只做一件事:读者打开文章时,悄悄问一下对方页面现在的标题和简介,有变化就换上新的文字。配图始终用构建时下载到站内的那张,不会从对方网站直接加载。
4
+
5
+ ## Worker 部署说明
6
+
7
+ ### 1. 目录和入口
8
+
9
+ - 目录:`worker/og/`,CI 不会部署它,需要时手动部署。
10
+ - 入口:`worker/og/index.mjs`(`wrangler.toml` 里的 `main`),请求处理逻辑在 `handler.mjs`,抓取和解析页面用的是与构建共用的 `scripts/link-meta-core.mjs`。
11
+
12
+ ```bash
13
+ npx wrangler deploy --config worker/og/wrangler.toml
14
+ ```
15
+
16
+ 部署后得到 `https://garden-og.<你的子域>.workers.dev`。
17
+
18
+ ### 2. 变量和绑定
19
+
20
+ 在 `wrangler.toml` 的 `[vars]` 里设置:
21
+
22
+ | 变量 | 作用 | 默认 |
23
+ |---|---|---|
24
+ | `SITE_ORIGIN` | 站点地址,Worker 从 `${SITE_ORIGIN}/link-allowlist.json` 读取允许的网址 | `https://hedon.top` |
25
+ | `ALLOWED_ORIGINS` | 允许调用的页面来源,逗号分隔;其他来源一律 403 | 同 `SITE_ORIGIN` |
26
+ | `ALLOW_LOCALHOST` | 设为 `"1"` 时也接受 `http://localhost:*` 和 `http://127.0.0.1:*`,方便本地预览 | `"1"` |
27
+ | `RATE_PER_MINUTE` | 每个 IP 每分钟最多请求次数,超出返回 429 | `30` |
28
+
29
+ 可选绑定 `RATE_LIMITER`:Cloudflare 的限流绑定(`wrangler.toml` 里有注释掉的示例),跨实例共享计数。不配置时按单个实例内存计数。
30
+
31
+ 缓存用 Workers 自带的边缘缓存(`caches.default`),不需要 KV 绑定:读到的结果缓存 24 小时,读取失败缓存 10 分钟,这段时间内不会再去请求对方网站。
32
+
33
+ ### 3. 允许名单从哪来,怎么更新
34
+
35
+ - 站点每次 `garden build` 时生成 `public/link-allowlist.json`,内容是已发布文章里所有写了 `{card}` 的链接。它随站点一起部署,不需要提交到仓库。
36
+ - Worker 不内置名单,运行时从站点读取:每个实例最多 5 分钟重新拉一次,Cloudflare 对这份文件的缓存也是 5 分钟。
37
+ - 所以新文章发布后,最迟约 10 分钟 Worker 就认得新链接,**不需要重新部署 Worker**。只有改了变量或 Worker 代码时才需要重新部署。
38
+ - 名单拉取失败时沿用上一份;一份都没有时拒绝所有网址。
39
+
40
+ ### 4. 站点里在哪里填 Worker 地址
41
+
42
+ 在站点仓库(例如 garden-blog)的 `site.config.yaml` 里:
43
+
44
+ ```yaml
45
+ linkPreview:
46
+ worker: https://garden-og.<你的子域>.workers.dev
47
+ ```
48
+
49
+ 不写这一项,页面就不会请求 Worker,卡片保持构建时的内容。只有包含 `{card}` 的文章页面才会加载刷新脚本。
50
+
51
+ ## 它会拒绝什么
52
+
53
+ - 不是来自 `ALLOWED_ORIGINS`(以及开启 `ALLOW_LOCALHOST` 时的 `http://localhost:*`)的请求。
54
+ - 不在站点 `/link-allowlist.json` 里的网址。
55
+ - 指向本机、内网、链接本地地址或非 80/443 端口的网址;跳转到这些地址的也一样,每一跳都会重新检查。
56
+ - 不是 HTML 的页面、超过 1 MB 或 5 秒没读完的页面。
57
+ - 同一个 IP 每分钟超过 `RATE_PER_MINUTE` 次的请求。
58
+
59
+ 浏览器端每张卡片的请求各自 6 秒超时,一张卡片慢不会影响其他卡片;离开页面时所有请求一起取消。Worker 出错或不可用时,页面保持构建时的内容。
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Link card refresh (layer 2). The article's cards are complete at build time;
3
+ * this Worker only lets a reader's browser pick up a newer title or
4
+ * description for a card URL the site already lists.
5
+ *
6
+ * Guards: only GET from the site's own origins; only URLs in the site's
7
+ * published allowlist; only public http(s) hosts, redirects re-checked; HTML
8
+ * only, at most 1 MB, 5 s; a per-IP request budget; results cached for a day,
9
+ * failures for ten minutes. It never
10
+ * returns a remote image URL: cards keep their build-time local picture.
11
+ */
12
+ import { fetchPageMeta } from "../../scripts/link-meta-core.mjs";
13
+
14
+ export const DEFAULTS = {
15
+ allowedOrigins: ["https://hedon.top"],
16
+ allowLocalhost: true,
17
+ allowlistTtlMs: 5 * 60 * 1000,
18
+ resultTtlSeconds: 24 * 60 * 60,
19
+ // A page that could not be read is not tried again for this long.
20
+ failureTtlSeconds: 10 * 60,
21
+ timeoutMs: 5000,
22
+ maxBytes: 1024 * 1024,
23
+ ratePerMinute: 30,
24
+ };
25
+
26
+ const LOCAL_ORIGIN = /^http:\/\/(localhost|127\.0\.0\.1)(:\d{1,5})?$/u;
27
+
28
+ export function originAllowed(origin, { allowedOrigins, allowLocalhost }) {
29
+ if (!origin) return false;
30
+ if (allowedOrigins.includes(origin)) return true;
31
+ return allowLocalhost && LOCAL_ORIGIN.test(origin);
32
+ }
33
+
34
+ /** Fixed one-minute windows per key. Good enough inside one isolate. */
35
+ export function createRateLimiter({ perMinute, now = () => Date.now() }) {
36
+ const windows = new Map();
37
+ return (key) => {
38
+ const minute = Math.floor(now() / 60000);
39
+ const entry = windows.get(key);
40
+ if (!entry || entry.minute !== minute) {
41
+ if (windows.size > 10000) windows.clear();
42
+ windows.set(key, { minute, count: 1 });
43
+ return true;
44
+ }
45
+ entry.count += 1;
46
+ return entry.count <= perMinute;
47
+ };
48
+ }
49
+
50
+ /** Normalise what the reader asked for, so `?url=` variations share a cache entry. */
51
+ export function normaliseTarget(raw) {
52
+ try {
53
+ const url = new URL(raw);
54
+ url.hash = "";
55
+ return url.toString();
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+
61
+ const json = (body, status, headers = {}) =>
62
+ new Response(JSON.stringify(body), {
63
+ status,
64
+ headers: { "content-type": "application/json; charset=utf-8", "x-content-type-options": "nosniff", ...headers },
65
+ });
66
+
67
+ /**
68
+ * Build the request handler. Everything outside is injected: `fetch` for the
69
+ * target pages, `loadAllowlist` for the site's list, `cache` (get/put by key)
70
+ * and `limit` (true when the IP may go on).
71
+ */
72
+ export function createHandler({
73
+ fetch: fetcher,
74
+ loadAllowlist,
75
+ cache = null,
76
+ limit,
77
+ options = {},
78
+ now = () => Date.now(),
79
+ }) {
80
+ const config = { ...DEFAULTS, ...options };
81
+ const limiter = limit ?? createRateLimiter({ perMinute: config.ratePerMinute, now });
82
+ let allowlist = null;
83
+ let allowlistAt = 0;
84
+
85
+ const currentAllowlist = async () => {
86
+ if (allowlist && now() - allowlistAt < config.allowlistTtlMs) return allowlist;
87
+ try {
88
+ const list = await loadAllowlist();
89
+ allowlist = new Set((Array.isArray(list) ? list : list?.urls ?? []).map(normaliseTarget).filter(Boolean));
90
+ allowlistAt = now();
91
+ } catch {
92
+ // Keep the last good list; with none, nothing is allowed.
93
+ allowlist ??= new Set();
94
+ }
95
+ return allowlist;
96
+ };
97
+
98
+ return async function handle(request, { ip = "" } = {}) {
99
+ const origin = request.headers.get("origin") ?? "";
100
+ if (!originAllowed(origin, config)) return json({ error: "origin not allowed" }, 403);
101
+ const cors = {
102
+ "access-control-allow-origin": origin,
103
+ "access-control-allow-methods": "GET, OPTIONS",
104
+ "access-control-max-age": "86400",
105
+ vary: "Origin",
106
+ };
107
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
108
+ if (request.method !== "GET") return json({ error: "method not allowed" }, 405, { ...cors, allow: "GET, OPTIONS" });
109
+
110
+ if (!(await limiter(ip || "unknown"))) return json({ error: "too many requests" }, 429, { ...cors, "retry-after": "60" });
111
+
112
+ const target = normaliseTarget(new URL(request.url).searchParams.get("url") ?? "");
113
+ if (!target) return json({ error: "missing url" }, 400, cors);
114
+ if (!(await currentAllowlist()).has(target)) return json({ error: "url not listed" }, 403, cors);
115
+
116
+ const cacheKey = `og:${target}`;
117
+ const cached = await cache?.get(cacheKey);
118
+ if (cached) return json(cached, 200, { ...cors, "cache-control": "public, max-age=3600", "x-cache": "hit" });
119
+
120
+ const failureKey = `og-fail:${target}`;
121
+ const failed = await cache?.get(failureKey);
122
+ if (failed) return json(failed, 502, { ...cors, "x-cache": "hit" });
123
+ const fail = async (body) => {
124
+ await cache?.put(failureKey, body, config.failureTtlSeconds);
125
+ return json(body, 502, { ...cors, "x-cache": "miss" });
126
+ };
127
+
128
+ let meta;
129
+ try {
130
+ meta = await fetchPageMeta(target, { fetch: fetcher, timeoutMs: config.timeoutMs, maxBytes: config.maxBytes });
131
+ } catch (error) {
132
+ return fail({ error: "could not read page", detail: String(error?.message ?? error).slice(0, 120) });
133
+ }
134
+ if (!meta.title) return fail({ error: "no title" });
135
+ // Text only. Pictures stay the local copies made at build time.
136
+ const body = Object.fromEntries(Object.entries({
137
+ url: target,
138
+ title: meta.title,
139
+ description: meta.description,
140
+ siteName: meta.siteName,
141
+ author: meta.author,
142
+ publishedTime: meta.publishedTime,
143
+ }).filter(([, value]) => value));
144
+ await cache?.put(cacheKey, body, config.resultTtlSeconds);
145
+ return json(body, 200, { ...cors, "cache-control": "public, max-age=3600", "x-cache": "miss" });
146
+ };
147
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cloudflare Worker entry for link card refresh. See README.md.
3
+ *
4
+ * Vars: SITE_ORIGIN (where /link-allowlist.json lives), ALLOWED_ORIGINS
5
+ * (comma separated), ALLOW_LOCALHOST ("1" to accept http://localhost:*),
6
+ * RATE_PER_MINUTE. Optional binding: RATE_LIMITER (Workers rate limiting).
7
+ */
8
+ import { createHandler, DEFAULTS } from "./handler.mjs";
9
+
10
+ let handler;
11
+ let handlerEnv;
12
+
13
+ function edgeCache() {
14
+ const store = globalThis.caches?.default;
15
+ if (!store) return null;
16
+ const keyUrl = (key) => `https://og-cache.internal/${encodeURIComponent(key)}`;
17
+ return {
18
+ async get(key) {
19
+ const hit = await store.match(keyUrl(key));
20
+ return hit ? hit.json() : null;
21
+ },
22
+ async put(key, value, ttl) {
23
+ await store.put(keyUrl(key), new Response(JSON.stringify(value), {
24
+ headers: { "content-type": "application/json", "cache-control": `public, max-age=${ttl}` },
25
+ }));
26
+ },
27
+ };
28
+ }
29
+
30
+ function build(env) {
31
+ const siteOrigin = String(env.SITE_ORIGIN ?? "https://hedon.top").replace(/\/+$/u, "");
32
+ const allowedOrigins = String(env.ALLOWED_ORIGINS ?? siteOrigin).split(",").map((value) => value.trim()).filter(Boolean);
33
+ const binding = env.RATE_LIMITER;
34
+ return createHandler({
35
+ fetch: (url, init) => fetch(url, init),
36
+ loadAllowlist: async () => {
37
+ const response = await fetch(`${siteOrigin}/link-allowlist.json`, { cf: { cacheTtl: 300 } });
38
+ if (!response.ok) throw new Error(`allowlist HTTP ${response.status}`);
39
+ return response.json();
40
+ },
41
+ cache: edgeCache(),
42
+ limit: binding ? async (key) => (await binding.limit({ key })).success : undefined,
43
+ options: {
44
+ allowedOrigins,
45
+ allowLocalhost: env.ALLOW_LOCALHOST === "1",
46
+ ratePerMinute: Number(env.RATE_PER_MINUTE) || DEFAULTS.ratePerMinute,
47
+ },
48
+ });
49
+ }
50
+
51
+ const worker = {
52
+ async fetch(request, env) {
53
+ if (!handler || handlerEnv !== env) {
54
+ handler = build(env);
55
+ handlerEnv = env;
56
+ }
57
+ return handler(request, { ip: request.headers.get("cf-connecting-ip") ?? "" });
58
+ },
59
+ };
60
+
61
+ export default worker;
@@ -0,0 +1,18 @@
1
+ # Link card refresh Worker. Not deployed by CI; deploy by hand when wanted:
2
+ # npx wrangler deploy --config worker/og/wrangler.toml
3
+ name = "garden-og"
4
+ main = "index.mjs"
5
+ compatibility_date = "2026-10-01"
6
+ workers_dev = true
7
+
8
+ [vars]
9
+ SITE_ORIGIN = "https://hedon.top"
10
+ ALLOWED_ORIGINS = "https://hedon.top"
11
+ ALLOW_LOCALHOST = "1"
12
+ RATE_PER_MINUTE = "30"
13
+
14
+ # Optional: Cloudflare's rate limiting binding, shared across isolates.
15
+ # [[ratelimits]]
16
+ # name = "RATE_LIMITER"
17
+ # namespace_id = "1001"
18
+ # simple = { limit = 30, period = 60 }