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.
- package/app/blog/[...path]/page.tsx +2 -2
- package/app/blog/page.tsx +5 -2
- package/app/columns/[column]/[...path]/page.tsx +2 -6
- package/app/components/ArticleReferences.tsx +31 -31
- package/app/components/BlogArchive.tsx +20 -28
- package/app/components/CoverImage.tsx +34 -0
- package/app/components/LinkCardRefresh.tsx +44 -0
- package/app/components/MarkdownArticle.tsx +94 -22
- package/app/components/SiteHeader.tsx +104 -28
- package/app/garden-modules.d.ts +20 -1
- package/app/globals.css +96 -41
- package/app/layout.tsx +1 -7
- package/app/lib/archive.ts +43 -0
- package/app/lib/content.ts +5 -39
- package/app/lib/generated-content.ts +1 -1
- package/app/lib/heading-number.ts +9 -0
- package/app/lib/remark-a2.mjs +4 -4
- package/app/lib/remark-link-card.mjs +85 -0
- package/app/lib/tag-filter.ts +52 -15
- package/app/page.tsx +3 -3
- package/app/rss.xml/route.ts +2 -1
- package/app/styles/a2.css +56 -12
- package/app/styles/tokens.css +1 -1
- package/bin/garden.mjs +6 -0
- package/package.json +1 -1
- package/scripts/build-content.mjs +53 -6
- package/scripts/cover-images.mjs +69 -0
- package/scripts/link-assets.mjs +284 -0
- package/scripts/link-meta-core.mjs +321 -0
- package/templates/site/site.config.yaml +2 -0
- package/ui/core/transform.mjs +4 -3
- package/ui/garden/card-refresh.mjs +92 -0
- package/ui/garden/markdown-links.mjs +84 -0
- package/ui/garden/references.mjs +21 -0
- package/ui/garden/same-site.mjs +63 -0
- package/ui/garden/search-full.mjs +131 -0
- package/ui/garden/search-index.mjs +102 -0
- package/ui/garden/search-match.mjs +96 -0
- package/ui/preview/dist/garden-ui.esm.js +3 -3
- package/ui/preview/dist/garden-ui.iife.js +2 -2
- package/worker/og/README.md +59 -0
- package/worker/og/handler.mjs +147 -0
- package/worker/og/index.mjs +61 -0
- package/worker/og/wrangler.toml +18 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time link assets, downloaded into the site's own static files so
|
|
3
|
+
* readers never ask a third party for them:
|
|
4
|
+
* - each external site's favicon;
|
|
5
|
+
* - for links written as `[text](url){card}`, the page's title, description,
|
|
6
|
+
* site name, author, date and a local copy of its og:image.
|
|
7
|
+
*
|
|
8
|
+
* Results are cached in `link-assets.json` (commit it). Every build refetches;
|
|
9
|
+
* when a fetch fails the cached copy stays, and when there is none the link
|
|
10
|
+
* simply shows no icon. Network trouble never fails the build.
|
|
11
|
+
*/
|
|
12
|
+
import { lookup } from "node:dns/promises";
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import matter from "gray-matter";
|
|
19
|
+
import { fetchFavicon, fetchImage, fetchPageMeta, isPublicUrl, linkHost } from "./link-meta-core.mjs";
|
|
20
|
+
import { parse as parseYaml } from "yaml";
|
|
21
|
+
import { markdownLinks } from "../ui/garden/markdown-links.mjs";
|
|
22
|
+
import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
|
|
23
|
+
|
|
24
|
+
export const CACHE_FILE = "link-assets.json";
|
|
25
|
+
export const ASSET_DIR = path.join("public", "link-assets");
|
|
26
|
+
export const ASSET_URL = "/link-assets";
|
|
27
|
+
|
|
28
|
+
const readJson = (file, fallback) => {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function emptyCache() {
|
|
37
|
+
return { version: 1, favicons: {}, cards: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readLinkCache(siteRoot) {
|
|
41
|
+
const cache = readJson(path.join(siteRoot, CACHE_FILE), emptyCache());
|
|
42
|
+
return { ...emptyCache(), ...cache, favicons: { ...(cache.favicons ?? {}) }, cards: { ...(cache.cards ?? {}) } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function markdownFiles(directory) {
|
|
46
|
+
if (!fs.existsSync(directory)) return [];
|
|
47
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
48
|
+
const full = path.join(directory, entry.name);
|
|
49
|
+
if (entry.isDirectory()) return markdownFiles(full);
|
|
50
|
+
return entry.name.endsWith(".md") ? [full] : [];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
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
|
+
|
|
62
|
+
/** External links across every post: the sites (for favicons) and the URLs. */
|
|
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 ?? "");
|
|
67
|
+
const hosts = new Map();
|
|
68
|
+
const cards = new Set();
|
|
69
|
+
const urls = new Set();
|
|
70
|
+
for (const file of markdownFiles(path.join(siteRoot, "content"))) {
|
|
71
|
+
const { content } = matter(fs.readFileSync(file, "utf8"));
|
|
72
|
+
for (const { url, card } of markdownLinks(content)) {
|
|
73
|
+
if (!isPublicUrl(url) || sameSite(url)) continue;
|
|
74
|
+
urls.add(url);
|
|
75
|
+
if (card) cards.add(url);
|
|
76
|
+
const host = linkHost(url);
|
|
77
|
+
if (host && !hosts.has(host)) hosts.set(host, url);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { hosts, cards, urls };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** In Node we can also resolve the name and refuse private addresses. */
|
|
84
|
+
export async function assertPublicDns(hostname) {
|
|
85
|
+
const host = hostname.replace(/^\[|\]$/gu, "");
|
|
86
|
+
if (isIP(host)) return;
|
|
87
|
+
const addresses = await lookup(host, { all: true });
|
|
88
|
+
for (const { address } of addresses) {
|
|
89
|
+
// 198.18.0.0/15 is what fake-IP proxies (Clash, Surge) hand out for every name.
|
|
90
|
+
if (/^198\.1[89]\./u.test(address)) continue;
|
|
91
|
+
if (!isPublicUrl(`http://${address.includes(":") ? `[${address}]` : address}/`)) {
|
|
92
|
+
throw new Error(`${hostname} resolves to a private address`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function guardedFetch(fetcher) {
|
|
98
|
+
return async (url, init) => {
|
|
99
|
+
await assertPublicDns(new URL(url).hostname);
|
|
100
|
+
return fetcher(url, init);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function pool(items, size, worker) {
|
|
105
|
+
const queue = [...items];
|
|
106
|
+
const runners = Array.from({ length: Math.min(size, queue.length) }, async () => {
|
|
107
|
+
while (queue.length) await worker(queue.shift());
|
|
108
|
+
});
|
|
109
|
+
await Promise.all(runners);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fileSafe = (host) => host.replace(/[^a-z0-9.-]/giu, "_");
|
|
113
|
+
const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex").slice(0, 10);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Refetch every favicon. Never throws: on any failure the cached entry (when
|
|
117
|
+
* its file is still there) is kept; otherwise the site gets no icon.
|
|
118
|
+
*/
|
|
119
|
+
export async function refreshLinkAssets({
|
|
120
|
+
siteRoot,
|
|
121
|
+
fetch: fetcher = globalThis.fetch,
|
|
122
|
+
dnsCheck = true,
|
|
123
|
+
concurrency = 8,
|
|
124
|
+
timeoutMs = 5000,
|
|
125
|
+
log = () => {},
|
|
126
|
+
} = {}) {
|
|
127
|
+
const cache = readLinkCache(siteRoot);
|
|
128
|
+
const next = emptyCache();
|
|
129
|
+
const stats = { fetched: 0, cached: 0, missing: 0 };
|
|
130
|
+
const cardStats = { fetched: 0, cached: 0, missing: 0 };
|
|
131
|
+
let hosts;
|
|
132
|
+
let cards;
|
|
133
|
+
try {
|
|
134
|
+
({ hosts, cards } = collectSiteLinks(siteRoot));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
log(`链接资源:读取文章失败,沿用缓存(${error.message})`);
|
|
137
|
+
return { cache, stats };
|
|
138
|
+
}
|
|
139
|
+
const assetRoot = path.join(siteRoot, ASSET_DIR);
|
|
140
|
+
const fetchImpl = dnsCheck ? guardedFetch(fetcher) : fetcher;
|
|
141
|
+
const keepCached = (host) => {
|
|
142
|
+
const old = cache.favicons[host];
|
|
143
|
+
if (old?.file && fs.existsSync(path.join(siteRoot, "public", old.file.replace(/^\//u, "")))) {
|
|
144
|
+
next.favicons[host] = old;
|
|
145
|
+
stats.cached += 1;
|
|
146
|
+
} else {
|
|
147
|
+
stats.missing += 1;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
await pool([...hosts], concurrency, async ([host, url]) => {
|
|
152
|
+
try {
|
|
153
|
+
const icon = await fetchFavicon(url, { fetch: fetchImpl, timeoutMs });
|
|
154
|
+
if (!icon) return keepCached(host);
|
|
155
|
+
const name = `${fileSafe(host)}-${digest(icon.bytes)}.${icon.ext}`;
|
|
156
|
+
fs.mkdirSync(path.join(assetRoot, "fav"), { recursive: true });
|
|
157
|
+
fs.writeFileSync(path.join(assetRoot, "fav", name), icon.bytes);
|
|
158
|
+
next.favicons[host] = { file: `${ASSET_URL}/fav/${name}` };
|
|
159
|
+
stats.fetched += 1;
|
|
160
|
+
} catch {
|
|
161
|
+
keepCached(host);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const assetExists = (file) => Boolean(file) && fs.existsSync(path.join(siteRoot, "public", file.replace(/^\//u, "")));
|
|
166
|
+
const keepCachedCard = (url) => {
|
|
167
|
+
const old = cache.cards[url];
|
|
168
|
+
if (old?.title) {
|
|
169
|
+
next.cards[url] = old.image && !assetExists(old.image) ? { ...old, image: undefined } : old;
|
|
170
|
+
cardStats.cached += 1;
|
|
171
|
+
} else {
|
|
172
|
+
cardStats.missing += 1;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
await pool([...cards], concurrency, async (url) => {
|
|
176
|
+
try {
|
|
177
|
+
const meta = await fetchPageMeta(url, { fetch: fetchImpl, timeoutMs });
|
|
178
|
+
if (!meta.title) return keepCachedCard(url);
|
|
179
|
+
let image;
|
|
180
|
+
if (meta.image) {
|
|
181
|
+
try {
|
|
182
|
+
const picture = await shrinkImage(await fetchImage(meta.image, { fetch: fetchImpl, timeoutMs: timeoutMs * 2 }));
|
|
183
|
+
const name = `${digest(Buffer.from(url))}-${digest(picture.bytes)}.${picture.ext}`;
|
|
184
|
+
fs.mkdirSync(path.join(assetRoot, "og"), { recursive: true });
|
|
185
|
+
fs.writeFileSync(path.join(assetRoot, "og", name), picture.bytes);
|
|
186
|
+
image = `${ASSET_URL}/og/${name}`;
|
|
187
|
+
} catch {
|
|
188
|
+
// A card without a picture is fine; keep the old one if we had it.
|
|
189
|
+
const old = cache.cards[url]?.image;
|
|
190
|
+
image = assetExists(old) ? old : undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
next.cards[url] = Object.fromEntries(Object.entries({
|
|
194
|
+
title: meta.title,
|
|
195
|
+
description: meta.description,
|
|
196
|
+
siteName: meta.siteName,
|
|
197
|
+
author: meta.author,
|
|
198
|
+
publishedTime: meta.publishedTime,
|
|
199
|
+
image,
|
|
200
|
+
}).filter(([, value]) => value));
|
|
201
|
+
cardStats.fetched += 1;
|
|
202
|
+
} catch {
|
|
203
|
+
keepCachedCard(url);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
next.cards = Object.fromEntries(Object.entries(next.cards).sort(([a], [b]) => a.localeCompare(b)));
|
|
207
|
+
|
|
208
|
+
next.favicons = Object.fromEntries(Object.entries(next.favicons).sort(([a], [b]) => a.localeCompare(b)));
|
|
209
|
+
try {
|
|
210
|
+
fs.writeFileSync(path.join(siteRoot, CACHE_FILE), `${JSON.stringify(next, null, 2)}\n`);
|
|
211
|
+
pruneAssets(siteRoot, next);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
log(`链接资源:写缓存失败(${error.message})`);
|
|
214
|
+
}
|
|
215
|
+
log(`链接图标:新取 ${stats.fetched},沿用缓存 ${stats.cached},没有图标 ${stats.missing}`);
|
|
216
|
+
if (cards.size) log(`链接卡片:新取 ${cardStats.fetched},沿用缓存 ${cardStats.cached},没有信息 ${cardStats.missing}`);
|
|
217
|
+
return { cache: next, stats, cardStats };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Drop files no entry points to any more. */
|
|
221
|
+
function pruneAssets(siteRoot, cache) {
|
|
222
|
+
const keep = new Set([
|
|
223
|
+
...Object.values(cache.favicons).map((entry) => entry.file),
|
|
224
|
+
...Object.values(cache.cards).map((entry) => entry.image).filter(Boolean),
|
|
225
|
+
]);
|
|
226
|
+
for (const kind of ["fav", "og"]) {
|
|
227
|
+
const dir = path.join(siteRoot, ASSET_DIR, kind);
|
|
228
|
+
if (!fs.existsSync(dir)) continue;
|
|
229
|
+
for (const name of fs.readdirSync(dir)) {
|
|
230
|
+
if (!keep.has(`${ASSET_URL}/${kind}/${name}`)) fs.rmSync(path.join(dir, name), { force: true });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Card pictures are shown at most ~170px wide (full column on phones). When
|
|
237
|
+
* the site has `sharp` installed, store a 720px WebP; otherwise keep the file.
|
|
238
|
+
*/
|
|
239
|
+
async function shrinkImage(picture) {
|
|
240
|
+
if (picture.ext === "svg" || picture.ext === "ico") return picture;
|
|
241
|
+
let sharp;
|
|
242
|
+
try {
|
|
243
|
+
sharp = (await import("sharp")).default;
|
|
244
|
+
} catch {
|
|
245
|
+
return picture;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const bytes = await sharp(Buffer.from(picture.bytes), { animated: false })
|
|
249
|
+
.resize({ width: 720, withoutEnlargement: true })
|
|
250
|
+
.webp({ quality: 78 })
|
|
251
|
+
.toBuffer();
|
|
252
|
+
return bytes.byteLength < picture.bytes.byteLength ? { bytes, ext: "webp", type: "image/webp" } : picture;
|
|
253
|
+
} catch {
|
|
254
|
+
return picture;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** What the article renderer needs: icons by site host, card details by URL (paths with base path). */
|
|
259
|
+
export function linkAssetsForRender(siteRoot, basePath = "") {
|
|
260
|
+
const cache = readLinkCache(siteRoot);
|
|
261
|
+
const exists = (file) => Boolean(file) && fs.existsSync(path.join(siteRoot, "public", file.replace(/^\//u, "")));
|
|
262
|
+
const favicons = {};
|
|
263
|
+
for (const [host, entry] of Object.entries(cache.favicons)) {
|
|
264
|
+
if (exists(entry?.file)) favicons[host] = `${basePath}${entry.file}`;
|
|
265
|
+
}
|
|
266
|
+
const cards = {};
|
|
267
|
+
for (const [url, entry] of Object.entries(cache.cards)) {
|
|
268
|
+
if (!entry?.title) continue;
|
|
269
|
+
const { image, ...rest } = entry;
|
|
270
|
+
cards[url] = exists(image) ? { ...rest, image: `${basePath}${image}` } : rest;
|
|
271
|
+
}
|
|
272
|
+
return { favicons, cards };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
276
|
+
const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
|
|
277
|
+
if (process.env.GARDEN_OFFLINE === "1") {
|
|
278
|
+
process.stdout.write("链接资源:离线模式,沿用缓存\n");
|
|
279
|
+
} else {
|
|
280
|
+
await refreshLinkAssets({ siteRoot, log: (line) => process.stdout.write(`${line}\n`) }).catch((error) => {
|
|
281
|
+
process.stdout.write(`链接资源:跳过(${error.message})\n`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link metadata: read a page's <head> and pull out its icon and Open Graph
|
|
3
|
+
* fields. No Node APIs here, so the build and the og Worker share it. Every
|
|
4
|
+
* network call takes an injected `fetch`, which keeps it testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
8
|
+
export const DEFAULT_MAX_HTML_BYTES = 1024 * 1024;
|
|
9
|
+
export const DEFAULT_MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
export const DEFAULT_MAX_ICON_BYTES = 1024 * 1024;
|
|
11
|
+
/** Icons kept on disk stay under this; a multi-size .ico is cut down first. */
|
|
12
|
+
export const MAX_STORED_ICON_BYTES = 200 * 1024;
|
|
13
|
+
const MAX_REDIRECTS = 4;
|
|
14
|
+
|
|
15
|
+
/** The site a link points to: its host without a leading "www.". */
|
|
16
|
+
export function linkHost(url) {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./u, "");
|
|
19
|
+
} catch {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u;
|
|
25
|
+
|
|
26
|
+
function privateIPv4(host) {
|
|
27
|
+
const match = IPV4.exec(host);
|
|
28
|
+
if (!match) return false;
|
|
29
|
+
const [a, b] = match.slice(1, 3).map(Number);
|
|
30
|
+
return a === 0 || a === 10 || a === 127 || a >= 224
|
|
31
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
32
|
+
|| (a === 169 && b === 254)
|
|
33
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
34
|
+
|| (a === 192 && b === 168)
|
|
35
|
+
|| (a === 198 && (b === 18 || b === 19));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function privateIPv6(host) {
|
|
39
|
+
const value = host.replace(/^\[|\]$/gu, "").toLowerCase();
|
|
40
|
+
if (!value.includes(":")) return false;
|
|
41
|
+
if (value === "::" || value === "::1") return true;
|
|
42
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/u.exec(value);
|
|
43
|
+
if (mapped) return privateIPv4(mapped[1]);
|
|
44
|
+
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(value);
|
|
45
|
+
if (mappedHex) {
|
|
46
|
+
const high = Number.parseInt(mappedHex[1], 16);
|
|
47
|
+
const low = Number.parseInt(mappedHex[2], 16);
|
|
48
|
+
return privateIPv4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
49
|
+
}
|
|
50
|
+
// NAT64 and IPv4-compatible forms can hide a private IPv4 address.
|
|
51
|
+
if (/^(64:ff9b::|::[0-9a-f]{1,4}:[0-9a-f]{1,4}$)/u.test(value)) return true;
|
|
52
|
+
return /^(fc|fd|fe8|fe9|fea|feb|ff)/u.test(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Only public web addresses may be fetched: http(s), default or standard
|
|
57
|
+
* ports, no loopback, private, link-local or internal hosts.
|
|
58
|
+
*/
|
|
59
|
+
export function isPublicUrl(input) {
|
|
60
|
+
let url;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(input);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return false;
|
|
67
|
+
if (url.username || url.password) return false;
|
|
68
|
+
if (url.port && !["80", "443"].includes(url.port)) return false;
|
|
69
|
+
const host = url.hostname.toLowerCase().replace(/\.$/u, "");
|
|
70
|
+
if (!host || !host.includes(".") && !host.includes(":")) return false;
|
|
71
|
+
if (host === "localhost" || /\.(localhost|local|internal|lan|home|arpa|test|invalid)$/u.test(host)) return false;
|
|
72
|
+
if (host === "metadata.google.internal") return false;
|
|
73
|
+
if (privateIPv4(host) || privateIPv6(host)) return false;
|
|
74
|
+
// Decimal or hex IPv4 spellings (http://2130706433/) are refused outright.
|
|
75
|
+
if (/^(0x[0-9a-f]+|\d+)$/iu.test(host)) return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const withTimeout = (timeoutMs) => {
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
82
|
+
return { signal: controller.signal, done: () => clearTimeout(timer) };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Fetch with manual redirects, re-checking every hop against isPublicUrl. */
|
|
86
|
+
export async function safeFetch(url, { fetch: fetcher = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, headers = {} } = {}) {
|
|
87
|
+
let current = url;
|
|
88
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
89
|
+
if (!isPublicUrl(current)) throw new Error(`blocked address: ${current}`);
|
|
90
|
+
const timer = withTimeout(timeoutMs);
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetcher(current, {
|
|
94
|
+
redirect: "manual",
|
|
95
|
+
signal: timer.signal,
|
|
96
|
+
headers: { "user-agent": "Mozilla/5.0 (compatible; GardenLinkPreview/1.0)", accept: "text/html,application/xhtml+xml,image/*;q=0.8,*/*;q=0.5", ...headers },
|
|
97
|
+
});
|
|
98
|
+
} finally {
|
|
99
|
+
timer.done();
|
|
100
|
+
}
|
|
101
|
+
if (response.status >= 300 && response.status < 400) {
|
|
102
|
+
const location = response.headers.get("location");
|
|
103
|
+
if (!location) throw new Error(`redirect without location: ${current}`);
|
|
104
|
+
current = new URL(location, current).toString();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return { response, url: current };
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`too many redirects: ${url}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read at most maxBytes; for HTML stop once </head> has arrived. */
|
|
113
|
+
export async function readCapped(response, maxBytes, { untilHead = false } = {}) {
|
|
114
|
+
const declared = Number(response.headers.get("content-length") || 0);
|
|
115
|
+
if (declared > maxBytes && !untilHead) throw new Error("response too large");
|
|
116
|
+
if (!response.body?.getReader) {
|
|
117
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
118
|
+
if (buffer.byteLength > maxBytes && !untilHead) throw new Error("response too large");
|
|
119
|
+
return buffer.slice(0, maxBytes);
|
|
120
|
+
}
|
|
121
|
+
const reader = response.body.getReader();
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let size = 0;
|
|
124
|
+
let tail = "";
|
|
125
|
+
const decoder = new TextDecoder();
|
|
126
|
+
for (;;) {
|
|
127
|
+
const { done, value } = await reader.read();
|
|
128
|
+
if (done) break;
|
|
129
|
+
chunks.push(value);
|
|
130
|
+
size += value.byteLength;
|
|
131
|
+
if (untilHead) {
|
|
132
|
+
tail = (tail + decoder.decode(value, { stream: true })).slice(-4096);
|
|
133
|
+
if (/<\/head\s*>|<body[\s>]/iu.test(tail)) break;
|
|
134
|
+
}
|
|
135
|
+
if (size > maxBytes) {
|
|
136
|
+
if (!untilHead) {
|
|
137
|
+
await reader.cancel().catch(() => {});
|
|
138
|
+
throw new Error("response too large");
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
await reader.cancel().catch(() => {});
|
|
144
|
+
const out = new Uint8Array(Math.min(size, maxBytes));
|
|
145
|
+
let offset = 0;
|
|
146
|
+
for (const chunk of chunks) {
|
|
147
|
+
const part = chunk.subarray(0, Math.max(0, out.byteLength - offset));
|
|
148
|
+
out.set(part, offset);
|
|
149
|
+
offset += part.byteLength;
|
|
150
|
+
if (offset >= out.byteLength) break;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const isHtmlType = (type) => /^(text\/html|application\/xhtml\+xml)\b/iu.test(type ?? "");
|
|
156
|
+
export const isImageType = (type) => /^image\/(png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon)\b/iu.test(type ?? "");
|
|
157
|
+
|
|
158
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", "#39": "'", nbsp: " " };
|
|
159
|
+
const decodeEntities = (value) => value
|
|
160
|
+
.replace(/&#x([0-9a-f]+);/giu, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
161
|
+
.replace(/&#(\d+);/gu, (_, dec) => String.fromCodePoint(Number(dec)))
|
|
162
|
+
.replace(/&([a-z0-9#]+);/giu, (whole, name) => ENTITIES[name.toLowerCase()] ?? whole);
|
|
163
|
+
|
|
164
|
+
function attributes(tag) {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const match of tag.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/gu)) {
|
|
167
|
+
out[match[1].toLowerCase()] = decodeEntities(match[3] ?? match[4] ?? match[5] ?? "");
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const clean = (value, max = 300) => {
|
|
173
|
+
const text = decodeEntities(String(value ?? "")).replace(/\s+/gu, " ").trim();
|
|
174
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const absolute = (value, base) => {
|
|
178
|
+
if (!value) return "";
|
|
179
|
+
try {
|
|
180
|
+
const url = new URL(value.trim(), base);
|
|
181
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : "";
|
|
182
|
+
} catch {
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Title, description, image, site name, author, date and icon candidates from a page's head. */
|
|
188
|
+
export function parseHead(html, baseUrl) {
|
|
189
|
+
const head = (/<head[\s>][\s\S]*?(<\/head>|$)/iu.exec(html)?.[0]) ?? html.slice(0, 200_000);
|
|
190
|
+
const meta = {};
|
|
191
|
+
for (const tag of head.match(/<meta\b[^>]*>/giu) ?? []) {
|
|
192
|
+
const attrs = attributes(tag);
|
|
193
|
+
const key = (attrs.property || attrs.name || attrs.itemprop || "").toLowerCase();
|
|
194
|
+
if (key && attrs.content !== undefined && meta[key] === undefined) meta[key] = attrs.content;
|
|
195
|
+
}
|
|
196
|
+
const icons = [];
|
|
197
|
+
for (const tag of head.match(/<link\b[^>]*>/giu) ?? []) {
|
|
198
|
+
const attrs = attributes(tag);
|
|
199
|
+
const rel = (attrs.rel ?? "").toLowerCase().split(/\s+/u);
|
|
200
|
+
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue;
|
|
201
|
+
const href = absolute(attrs.href, baseUrl);
|
|
202
|
+
if (!href) continue;
|
|
203
|
+
const size = Number(/(\d+)x\d+/u.exec(attrs.sizes ?? "")?.[1] ?? 0);
|
|
204
|
+
// Prefer a small icon close to 32px, then the apple icon as a last resort.
|
|
205
|
+
const score = (rel.includes("apple-touch-icon") ? 1000 : 0) + (size ? Math.abs(size - 32) : 16);
|
|
206
|
+
icons.push({ href, score });
|
|
207
|
+
}
|
|
208
|
+
icons.sort((left, right) => left.score - right.score);
|
|
209
|
+
const title = /<title[^>]*>([\s\S]*?)<\/title>/iu.exec(head)?.[1];
|
|
210
|
+
const published = meta["article:published_time"] || meta["og:published_time"] || meta.date || meta.datepublished || meta["parsely-pub-date"] || "";
|
|
211
|
+
const publishedTime = published && !Number.isNaN(Date.parse(published)) ? new Date(Date.parse(published)).toISOString().slice(0, 10) : "";
|
|
212
|
+
const author = meta["article:author"] || meta.author || meta["twitter:creator"] || "";
|
|
213
|
+
return {
|
|
214
|
+
title: clean(meta["og:title"] || meta["twitter:title"] || title, 200),
|
|
215
|
+
description: clean(meta["og:description"] || meta["twitter:description"] || meta.description, 300),
|
|
216
|
+
image: absolute(meta["og:image:secure_url"] || meta["og:image"] || meta["og:image:url"] || meta["twitter:image"], baseUrl),
|
|
217
|
+
siteName: clean(meta["og:site_name"] || meta["application-name"], 80),
|
|
218
|
+
// article:author is sometimes a profile URL; only keep readable names.
|
|
219
|
+
author: /^https?:\/\//iu.test(author) ? "" : clean(author, 80),
|
|
220
|
+
publishedTime,
|
|
221
|
+
icons: icons.map((icon) => icon.href),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A page's head, fetched safely: HTML only, at most maxBytes, stopping at </head>. */
|
|
226
|
+
export async function fetchHead(url, { fetch: fetcher, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_HTML_BYTES } = {}) {
|
|
227
|
+
const { response, url: finalUrl } = await safeFetch(url, { fetch: fetcher, timeoutMs });
|
|
228
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
229
|
+
const type = response.headers.get("content-type");
|
|
230
|
+
if (!isHtmlType(type)) throw new Error(`not HTML: ${type}`);
|
|
231
|
+
const bytes = await readCapped(response, maxBytes, { untilHead: true });
|
|
232
|
+
return { html: new TextDecoder().decode(bytes), url: finalUrl };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Open Graph fields for a page. */
|
|
236
|
+
export async function fetchPageMeta(url, options = {}) {
|
|
237
|
+
const { html, url: finalUrl } = await fetchHead(url, options);
|
|
238
|
+
return { ...parseHead(html, finalUrl), url: finalUrl };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const EXTENSIONS = {
|
|
242
|
+
"image/png": "png",
|
|
243
|
+
"image/jpeg": "jpg",
|
|
244
|
+
"image/gif": "gif",
|
|
245
|
+
"image/webp": "webp",
|
|
246
|
+
"image/avif": "avif",
|
|
247
|
+
"image/svg+xml": "svg",
|
|
248
|
+
"image/x-icon": "ico",
|
|
249
|
+
"image/vnd.microsoft.icon": "ico",
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Download an image (bytes plus a file extension), or throw. */
|
|
253
|
+
export async function fetchImage(url, { fetch: fetcher, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_IMAGE_BYTES } = {}) {
|
|
254
|
+
const { response } = await safeFetch(url, { fetch: fetcher, timeoutMs });
|
|
255
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
256
|
+
let type = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
257
|
+
// Many servers send /favicon.ico as octet-stream.
|
|
258
|
+
if (!isImageType(type) && /\.ico(\?|$)/iu.test(url) && /^(application\/octet-stream|)$/u.test(type)) type = "image/x-icon";
|
|
259
|
+
if (!isImageType(type)) throw new Error(`not an image: ${type}`);
|
|
260
|
+
const bytes = await readCapped(response, maxBytes);
|
|
261
|
+
if (!bytes.byteLength) throw new Error("empty image");
|
|
262
|
+
return { bytes, ext: EXTENSIONS[type] ?? "img", type };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A multi-size .ico can be hundreds of KB. Keep just the image closest to
|
|
267
|
+
* 32px, as a one-image .ico. Returns the input when it is not a valid .ico.
|
|
268
|
+
*/
|
|
269
|
+
export function trimIco(bytes, target = 32) {
|
|
270
|
+
if (bytes.byteLength < 22) return bytes;
|
|
271
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
272
|
+
if (view.getUint16(0, true) !== 0 || view.getUint16(2, true) !== 1) return bytes;
|
|
273
|
+
const count = view.getUint16(4, true);
|
|
274
|
+
if (!count || 6 + count * 16 > bytes.byteLength) return bytes;
|
|
275
|
+
let best = null;
|
|
276
|
+
for (let index = 0; index < count; index += 1) {
|
|
277
|
+
const at = 6 + index * 16;
|
|
278
|
+
const width = bytes[at] || 256;
|
|
279
|
+
const size = view.getUint32(at + 8, true);
|
|
280
|
+
const offset = view.getUint32(at + 12, true);
|
|
281
|
+
if (!size || offset + size > bytes.byteLength) continue;
|
|
282
|
+
const bits = view.getUint16(at + 6, true);
|
|
283
|
+
// Closest to the target, never smaller than 16 when there is a choice, then deeper colour.
|
|
284
|
+
const score = Math.abs(width - target) + (width < 16 ? 100 : 0) - bits / 100;
|
|
285
|
+
if (!best || score < best.score) best = { at, size, offset, score };
|
|
286
|
+
}
|
|
287
|
+
if (!best || count === 1) return bytes;
|
|
288
|
+
const out = new Uint8Array(22 + best.size);
|
|
289
|
+
const outView = new DataView(out.buffer);
|
|
290
|
+
outView.setUint16(2, 1, true);
|
|
291
|
+
outView.setUint16(4, 1, true);
|
|
292
|
+
out.set(bytes.subarray(best.at, best.at + 12), 6);
|
|
293
|
+
outView.setUint32(18, 22, true);
|
|
294
|
+
out.set(bytes.subarray(best.offset, best.offset + best.size), 22);
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A site's icon: the <link rel=icon> candidates first, then /favicon.ico. */
|
|
299
|
+
export async function fetchFavicon(pageUrl, { fetch: fetcher, timeoutMs = DEFAULT_TIMEOUT_MS, head } = {}) {
|
|
300
|
+
const origin = new URL(pageUrl).origin;
|
|
301
|
+
let candidates = head?.icons ?? [];
|
|
302
|
+
if (!head) {
|
|
303
|
+
try {
|
|
304
|
+
const { html, url } = await fetchHead(`${origin}/`, { fetch: fetcher, timeoutMs });
|
|
305
|
+
candidates = parseHead(html, url).icons;
|
|
306
|
+
} catch {
|
|
307
|
+
candidates = [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const candidate of [...candidates, `${origin}/favicon.ico`]) {
|
|
311
|
+
try {
|
|
312
|
+
const icon = await fetchImage(candidate, { fetch: fetcher, timeoutMs, maxBytes: DEFAULT_MAX_ICON_BYTES });
|
|
313
|
+
const bytes = icon.ext === "ico" ? trimIco(icon.bytes) : icon.bytes;
|
|
314
|
+
if (bytes.byteLength > MAX_STORED_ICON_BYTES) continue;
|
|
315
|
+
return { ...icon, bytes, source: candidate };
|
|
316
|
+
} catch {
|
|
317
|
+
// try the next candidate
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
package/ui/core/transform.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripCardMarkers } from "../garden/markdown-links.mjs";
|
|
1
2
|
import { parse as parseYaml } from "yaml";
|
|
2
3
|
import { compileUiBlock, jsonFence, readPayload } from "./compile.mjs";
|
|
3
4
|
import { findBlocks, scanFences } from "./fence.mjs";
|
|
@@ -75,7 +76,7 @@ const escapeHtml = (value) =>
|
|
|
75
76
|
|
|
76
77
|
/** Readable text for search and word count: captions and snapshot text, never intent. */
|
|
77
78
|
export function contentToSearchText(content) {
|
|
78
|
-
return replaceFences(content, (fence) => {
|
|
79
|
+
return replaceFences(stripCardMarkers(content), (fence) => {
|
|
79
80
|
if (fence.lang === "ui") {
|
|
80
81
|
const payload = readPayload(fence.body);
|
|
81
82
|
if (!payload) return " ";
|
|
@@ -99,7 +100,7 @@ export function contentToSearchText(content) {
|
|
|
99
100
|
|
|
100
101
|
/** Markdown for RSS: snapshots become an HTML block, widgets become caption + link. */
|
|
101
102
|
export function contentForFeed(content, { pageUrl = "", siteUrl = "" } = {}) {
|
|
102
|
-
return replaceFences(content, (fence) => {
|
|
103
|
+
return replaceFences(stripCardMarkers(content), (fence) => {
|
|
103
104
|
if (fence.lang === "ui") {
|
|
104
105
|
const payload = readPayload(fence.body);
|
|
105
106
|
if (!payload) return "";
|
|
@@ -118,7 +119,7 @@ export function contentForFeed(content, { pageUrl = "", siteUrl = "" } = {}) {
|
|
|
118
119
|
|
|
119
120
|
/** Markdown for CSDN / 知乎 / 掘金: snapshot Markdown plus a link back to the block. */
|
|
120
121
|
export function contentForDistribution(content, { pageUrl = "", siteUrl = "" } = {}) {
|
|
121
|
-
return replaceFences(content, (fence) => {
|
|
122
|
+
return replaceFences(stripCardMarkers(content), (fence) => {
|
|
122
123
|
if (fence.lang === "ui") {
|
|
123
124
|
const payload = readPayload(fence.body);
|
|
124
125
|
if (!payload) return "";
|