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,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for refreshing link cards in the browser. The DOM side lives in
|
|
3
|
+
* app/components/LinkCardRefresh.tsx.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** "2025-01-30" → "2025 年 1 月 30 日"; anything else → "". */
|
|
7
|
+
export function cardDate(value) {
|
|
8
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/u.exec(value ?? "");
|
|
9
|
+
return match ? `${match[1]} 年 ${Number(match[2])} 月 ${Number(match[3])} 日` : "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LIMITS = { title: 200, description: 300, siteName: 80, author: 80 };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Which text fields to change. Only fields the card already shows are
|
|
16
|
+
* touched, and empty answers never blank a field, so a refresh never adds or
|
|
17
|
+
* removes a line.
|
|
18
|
+
*/
|
|
19
|
+
export function cardUpdates(current, fresh) {
|
|
20
|
+
if (!fresh || typeof fresh !== "object") return {};
|
|
21
|
+
const changes = {};
|
|
22
|
+
for (const [field, shown] of Object.entries(current)) {
|
|
23
|
+
let next = fresh[field];
|
|
24
|
+
if (typeof next !== "string" || !next.trim()) continue;
|
|
25
|
+
next = next.replace(/\s+/gu, " ").trim();
|
|
26
|
+
if (field === "publishedTime") next = cardDate(next);
|
|
27
|
+
else if (LIMITS[field] && next.length > LIMITS[field]) next = `${next.slice(0, LIMITS[field] - 1)}…`;
|
|
28
|
+
if (next && next !== shown) changes[field] = next;
|
|
29
|
+
}
|
|
30
|
+
return changes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Only cards the reader has not reached yet change, so nothing moves under their eyes. */
|
|
34
|
+
export function safeToUpdate(cardTop, viewportHeight) {
|
|
35
|
+
return cardTop > viewportHeight;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Worker URL for one card. */
|
|
39
|
+
export function refreshUrl(endpoint, cardUrl) {
|
|
40
|
+
const base = String(endpoint ?? "").replace(/\/+$/u, "");
|
|
41
|
+
if (!/^https:\/\/|^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/u.test(base)) return "";
|
|
42
|
+
return `${base}/?url=${encodeURIComponent(cardUrl)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A signal for one request: aborted when this request takes longer than
|
|
47
|
+
* `ms`, or when `shared` aborts (the page goes away). Each card gets its own,
|
|
48
|
+
* so one slow card never cancels the others. Uses AbortSignal.any/timeout when
|
|
49
|
+
* the browser has them, a controller and a timer otherwise.
|
|
50
|
+
*
|
|
51
|
+
* @param {AbortSignal} shared
|
|
52
|
+
* @param {number} ms
|
|
53
|
+
* @returns {{ signal: AbortSignal; done: () => void }}
|
|
54
|
+
*/
|
|
55
|
+
export function requestSignal(shared, ms, { Signal = globalThis.AbortSignal, Controller = globalThis.AbortController } = {}) {
|
|
56
|
+
if (typeof Signal?.any === "function" && typeof Signal?.timeout === "function") {
|
|
57
|
+
return { signal: Signal.any([shared, Signal.timeout(ms)]), done: () => {} };
|
|
58
|
+
}
|
|
59
|
+
const controller = new Controller();
|
|
60
|
+
const abort = () => controller.abort();
|
|
61
|
+
const timer = setTimeout(abort, ms);
|
|
62
|
+
if (shared.aborted) abort();
|
|
63
|
+
else shared.addEventListener("abort", abort, { once: true });
|
|
64
|
+
return {
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
done: () => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
shared.removeEventListener("abort", abort);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const CARD_TIMEOUT_MS = 6000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ask the Worker for one card's current text. Resolves to the JSON answer, or
|
|
77
|
+
* null on any failure, timeout or abort.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} target
|
|
80
|
+
* @param {{ shared: AbortSignal; fetcher?: typeof fetch; timeoutMs?: number; signalOptions?: object }} options
|
|
81
|
+
*/
|
|
82
|
+
export async function fetchCardText(target, { shared, fetcher = globalThis.fetch, timeoutMs = CARD_TIMEOUT_MS, signalOptions } = {}) {
|
|
83
|
+
const { signal, done } = requestSignal(shared, timeoutMs, signalOptions);
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetcher(target, { mode: "cors", credentials: "omit", signal });
|
|
86
|
+
return response.ok ? await response.json() : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
} finally {
|
|
90
|
+
done();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External links written in a post body. Code (fenced blocks, indented blocks
|
|
3
|
+
* and inline spans) is skipped, so a URL in a snippet is never a link.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/u;
|
|
7
|
+
|
|
8
|
+
/** Call `visit(text)` for every stretch of Markdown that is not code; returns the rebuilt text. */
|
|
9
|
+
export function mapOutsideCode(markdown, visit) {
|
|
10
|
+
const lines = String(markdown ?? "").split("\n");
|
|
11
|
+
const out = [];
|
|
12
|
+
let fence = "";
|
|
13
|
+
let prose = [];
|
|
14
|
+
const flush = () => {
|
|
15
|
+
if (!prose.length) return;
|
|
16
|
+
out.push(mapInline(prose.join("\n"), visit));
|
|
17
|
+
prose = [];
|
|
18
|
+
};
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
if (fence) {
|
|
21
|
+
out.push(line);
|
|
22
|
+
if (new RegExp(`^ {0,3}${fence[0]}{${fence.length},}\\s*$`, "u").test(line)) fence = "";
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const open = FENCE.exec(line);
|
|
26
|
+
if (open) {
|
|
27
|
+
flush();
|
|
28
|
+
fence = open[1];
|
|
29
|
+
out.push(line);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
prose.push(line);
|
|
33
|
+
}
|
|
34
|
+
flush();
|
|
35
|
+
return out.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Inline code spans: a run of backticks closed by the same-length run.
|
|
39
|
+
function mapInline(text, visit) {
|
|
40
|
+
let result = "";
|
|
41
|
+
let index = 0;
|
|
42
|
+
const pattern = /(`+)[\s\S]*?(?<!`)\1(?!`)/gu;
|
|
43
|
+
for (const match of text.matchAll(pattern)) {
|
|
44
|
+
result += visit(text.slice(index, match.index)) + match[0];
|
|
45
|
+
index = match.index + match[0].length;
|
|
46
|
+
}
|
|
47
|
+
return result + visit(text.slice(index));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LINK = /(!?)\[(?:[^\[\]]|\[[^\]]*\])*\]\(\s*<?(https?:\/\/[^\s)>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)(\{card\})?/giu;
|
|
51
|
+
const AUTOLINK = /<(https?:\/\/[^\s>]+)>/giu;
|
|
52
|
+
const BARE = /(^|[\s((::,,。])(https?:\/\/[^\s<>()()\],。、]+[^\s<>()()\].,;:!?,。、;:!?'"])/giu;
|
|
53
|
+
|
|
54
|
+
/** Every external link in the body, in order: `{ url, card }`. Images are left out. */
|
|
55
|
+
export function markdownLinks(markdown) {
|
|
56
|
+
const links = [];
|
|
57
|
+
mapOutsideCode(markdown, (text) => {
|
|
58
|
+
for (const match of text.matchAll(LINK)) {
|
|
59
|
+
if (match[1] !== "!") links.push({ url: match[2], card: Boolean(match[3]) });
|
|
60
|
+
}
|
|
61
|
+
const rest = text.replace(LINK, " ");
|
|
62
|
+
for (const match of rest.matchAll(AUTOLINK)) links.push({ url: match[1], card: false });
|
|
63
|
+
for (const match of rest.replace(AUTOLINK, " ").matchAll(BARE)) links.push({ url: match[2], card: false });
|
|
64
|
+
return text;
|
|
65
|
+
});
|
|
66
|
+
return links;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The marker that asks for a link card: `[text](url){card}`, nothing in between. */
|
|
70
|
+
export const CARD_MARKER = "{card}";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The same Markdown without `{card}` markers, for excerpts, RSS and the search
|
|
74
|
+
* index. Markers inside code stay, since there they are literal text.
|
|
75
|
+
*/
|
|
76
|
+
export function stripCardMarkers(markdown) {
|
|
77
|
+
if (!String(markdown ?? "").includes(CARD_MARKER)) return markdown;
|
|
78
|
+
return mapOutsideCode(markdown, (text) => text.replace(LINK, (whole, bang, url, card) => (card ? whole.slice(0, -CARD_MARKER.length) : whole)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** URLs the author asked to show as cards, in order, without repeats. */
|
|
82
|
+
export function cardUrls(markdown) {
|
|
83
|
+
return [...new Set(markdownLinks(markdown).filter((link) => link.card).map((link) => link.url))];
|
|
84
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site a reference points to, shown after its title: the link's host
|
|
3
|
+
* without a leading "www.". Empty for missing or unparsable links.
|
|
4
|
+
*/
|
|
5
|
+
export function referenceDomain(url) {
|
|
6
|
+
if (typeof url !== "string" || !url.trim()) return "";
|
|
7
|
+
try {
|
|
8
|
+
const { hostname, protocol } = new URL(url.trim());
|
|
9
|
+
if (protocol !== "http:" && protocol !== "https:") return "";
|
|
10
|
+
return hostname.toLowerCase().replace(/^www\./, "");
|
|
11
|
+
} catch {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Short lists stay in one column; two columns start at this many entries. */
|
|
17
|
+
export const REFERENCE_SPLIT_AT = 6;
|
|
18
|
+
|
|
19
|
+
export function referenceColumns(count) {
|
|
20
|
+
return count >= REFERENCE_SPLIT_AT ? 2 : 1;
|
|
21
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background loading of the full-text index, in the browser.
|
|
3
|
+
*
|
|
4
|
+
* The file name carries a hash, but GitHub Pages only lets browsers cache it
|
|
5
|
+
* for ten minutes, so the parsed docs are also kept in IndexedDB under that
|
|
6
|
+
* hash. Pages after the first read them from there without a request. Any
|
|
7
|
+
* storage problem quietly falls back to the network.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const STORE = "full";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `[[path, text], ...]` from the file's contents (`{ hash, texts, docs }`).
|
|
14
|
+
*
|
|
15
|
+
* @returns {Array<[string, string]>}
|
|
16
|
+
*/
|
|
17
|
+
export function expandFullIndex(data) {
|
|
18
|
+
if (!Array.isArray(data?.texts) || !Array.isArray(data?.docs)) throw new Error("full-text index is malformed");
|
|
19
|
+
return data.docs.map(([url, index]) => [url, String(data.texts[index] ?? "")]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function request(req) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
req.onsuccess = () => resolve(req.result);
|
|
25
|
+
req.onerror = () => reject(req.error);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* IndexedDB holding one entry: the docs for the current hash. One database per site path.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} [scope]
|
|
33
|
+
* @param {IDBFactory | undefined} [idb]
|
|
34
|
+
*/
|
|
35
|
+
export function createIdbStore(scope = "", idb = globalThis.indexedDB) {
|
|
36
|
+
let opened = null;
|
|
37
|
+
const open = () => {
|
|
38
|
+
if (!idb) return Promise.reject(new Error("no IndexedDB"));
|
|
39
|
+
opened ??= new Promise((resolve, reject) => {
|
|
40
|
+
const req = idb.open(`garden-search${scope}`, 1);
|
|
41
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
42
|
+
req.onsuccess = () => resolve(req.result);
|
|
43
|
+
req.onerror = () => reject(req.error);
|
|
44
|
+
req.onblocked = () => reject(new Error("blocked"));
|
|
45
|
+
}).catch((error) => {
|
|
46
|
+
opened = null;
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
return opened;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
async get(hash) {
|
|
53
|
+
const db = await open();
|
|
54
|
+
return (await request(db.transaction(STORE, "readonly").objectStore(STORE).get(hash))) ?? null;
|
|
55
|
+
},
|
|
56
|
+
/** Store the docs for this hash and drop every other entry. */
|
|
57
|
+
async replace(hash, docs) {
|
|
58
|
+
const db = await open();
|
|
59
|
+
const tx = db.transaction(STORE, "readwrite");
|
|
60
|
+
const store = tx.objectStore(STORE);
|
|
61
|
+
store.clear();
|
|
62
|
+
store.put(docs, hash);
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
tx.oncomplete = resolve;
|
|
65
|
+
tx.onerror = () => reject(tx.error);
|
|
66
|
+
tx.onabort = () => reject(tx.error);
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `[[path, text], ...]` for this hash: from the store when it has it, otherwise from the network.
|
|
74
|
+
*
|
|
75
|
+
* @param {{ url: string; hash: string; store?: { get(hash: string): Promise<unknown>; replace(hash: string, docs: unknown): Promise<void> } | null; fetcher?: typeof fetch }} options
|
|
76
|
+
* @returns {Promise<Array<[string, string]>>}
|
|
77
|
+
*/
|
|
78
|
+
export async function loadFullIndex({ url, hash, store, fetcher = globalThis.fetch }) {
|
|
79
|
+
try {
|
|
80
|
+
const cached = await store?.get(hash);
|
|
81
|
+
if (cached) return expandFullIndex(cached);
|
|
82
|
+
} catch {
|
|
83
|
+
// Storage unavailable or entry unreadable: use the network.
|
|
84
|
+
}
|
|
85
|
+
const response = await fetcher(url);
|
|
86
|
+
if (!response.ok) throw new Error(`full-text index ${response.status}`);
|
|
87
|
+
const data = await response.json();
|
|
88
|
+
const docs = expandFullIndex(data);
|
|
89
|
+
try {
|
|
90
|
+
await store?.replace(data.hash ?? hash, data);
|
|
91
|
+
} catch {
|
|
92
|
+
// Not cached this time; the next page fetches it again.
|
|
93
|
+
}
|
|
94
|
+
return docs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The reader asked their browser to save data. */
|
|
98
|
+
export function saveDataOn(nav = globalThis.navigator) {
|
|
99
|
+
return Boolean(nav?.connection?.saveData);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run `task` once the page has fully loaded and the browser is idle, so it
|
|
104
|
+
* never competes with rendering. Falls back to a timer where
|
|
105
|
+
* requestIdleCallback is missing. Returns a cancel function.
|
|
106
|
+
*/
|
|
107
|
+
/**
|
|
108
|
+
* @param {() => void} task
|
|
109
|
+
* @param {{ win?: any; timeout?: number; fallbackDelay?: number }} [options]
|
|
110
|
+
*/
|
|
111
|
+
export function whenIdleAfterLoad(task, { win = globalThis.window, timeout = 4000, fallbackDelay = 1200 } = {}) {
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
let handle = null;
|
|
114
|
+
const run = () => {
|
|
115
|
+
if (!cancelled) task();
|
|
116
|
+
};
|
|
117
|
+
const schedule = () => {
|
|
118
|
+
if (cancelled) return;
|
|
119
|
+
handle = typeof win.requestIdleCallback === "function"
|
|
120
|
+
? { idle: win.requestIdleCallback(run, { timeout }) }
|
|
121
|
+
: { timer: win.setTimeout(run, fallbackDelay) };
|
|
122
|
+
};
|
|
123
|
+
if (win.document?.readyState === "complete") schedule();
|
|
124
|
+
else win.addEventListener("load", schedule, { once: true });
|
|
125
|
+
return () => {
|
|
126
|
+
cancelled = true;
|
|
127
|
+
win.removeEventListener?.("load", schedule);
|
|
128
|
+
if (handle?.idle != null) win.cancelIdleCallback?.(handle.idle);
|
|
129
|
+
if (handle?.timer != null) win.clearTimeout?.(handle.timer);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two search indexes, both plain JSON files next to the site, never part of
|
|
3
|
+
* the page scripts:
|
|
4
|
+
* - search-index.json: titles, sections, tags, dates and short excerpts,
|
|
5
|
+
* loaded when search is about to open;
|
|
6
|
+
* - search-full.<hash>.json: the plain text of every body, fetched in the
|
|
7
|
+
* background after the page has loaded and kept in IndexedDB by hash.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { contentToSearchText } from "../core/transform.mjs";
|
|
11
|
+
|
|
12
|
+
/** Plain text of a post body, as search and word counts see it. */
|
|
13
|
+
export function searchableText(markdown) {
|
|
14
|
+
return contentToSearchText(markdown)
|
|
15
|
+
.replace(/```[^\n]*\n([\s\S]*?)```/g, " $1 ")
|
|
16
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, " $1 ")
|
|
17
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, " $1 ")
|
|
18
|
+
.replace(/<[^>]+>/g, " ")
|
|
19
|
+
.replace(/[#>*_~`|[\]{}()]/g, " ")
|
|
20
|
+
.replace(/\s+/g, " ")
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Code blocks longer than this many lines are left out of the full-text index. */
|
|
25
|
+
export const MAX_INDEXED_CODE_LINES = 4;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Body text for full-text search: markdown, HTML and math blocks stripped,
|
|
29
|
+
* short code blocks kept as words, long ones dropped.
|
|
30
|
+
*/
|
|
31
|
+
export function fullText(markdown) {
|
|
32
|
+
// Widgets and snapshots become their captions first, so they are not mistaken for long code.
|
|
33
|
+
const withoutLongCode = contentToSearchText(String(markdown ?? ""))
|
|
34
|
+
.replace(/^([ \t]*)(`{3,}|~{3,})[^\n]*\n([\s\S]*?)^\1\2[ \t]*$/gmu, (block, _indent, _fence, body) =>
|
|
35
|
+
body.split("\n").filter((line) => line.trim()).length > MAX_INDEXED_CODE_LINES ? "\n" : block)
|
|
36
|
+
.replace(/^\$\$[\s\S]*?^\$\$[ \t]*$/gmu, "\n")
|
|
37
|
+
// List and task markers, callout labels and footnote markers are not words.
|
|
38
|
+
.replace(/^[ \t>]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/gmu, "")
|
|
39
|
+
.replace(/\[![A-Za-z]+\][+-]?/gu, " ")
|
|
40
|
+
.replace(/\[\^[^\]\s]+\]:?/gu, " ");
|
|
41
|
+
// Underscores inside identifiers (snake_case) are part of the word, not emphasis.
|
|
42
|
+
return searchableText(withoutLongCode.replace(/(?<=[\p{L}\p{N}])_(?=[\p{L}\p{N}])/gu, "\u0001"))
|
|
43
|
+
.replaceAll("\u0001", "_")
|
|
44
|
+
.replace(/!\[[^\]]*\]\[[^\]]*\]/gu, " ")
|
|
45
|
+
.replace(/https?:\/\/\S+/gu, " ")
|
|
46
|
+
.replace(/&[a-z]+;|&#\d+;/giu, " ")
|
|
47
|
+
.replace(/\s+/gu, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const EXCERPT_LENGTH = 240;
|
|
52
|
+
|
|
53
|
+
const excerpt = (text, length = EXCERPT_LENGTH) => {
|
|
54
|
+
const chars = Array.from(text);
|
|
55
|
+
return chars.length > length ? `${chars.slice(0, length).join("")}…` : text;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const columnEntryPath = (entry) =>
|
|
59
|
+
entry.column && entry.path.startsWith(`${entry.column}/`) ? entry.path.slice(entry.column.length + 1) : entry.path;
|
|
60
|
+
|
|
61
|
+
/** Records in the same order the header has always listed them: posts, column entries, thoughts. */
|
|
62
|
+
export function buildSearchIndex({ posts = [], columns = [], thoughts = [] }, { length = EXCERPT_LENGTH } = {}) {
|
|
63
|
+
const record = (item, extra) => {
|
|
64
|
+
const text = searchableText(item.content ?? "");
|
|
65
|
+
return {
|
|
66
|
+
title: item.title,
|
|
67
|
+
description: item.description ?? "",
|
|
68
|
+
date: item.date,
|
|
69
|
+
tags: item.tags ?? [],
|
|
70
|
+
content: excerpt(text, length),
|
|
71
|
+
...extra(text),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
return [
|
|
75
|
+
...posts.map((item) => record(item, () => ({ section: "文章", path: `/blog/${item.path}` }))),
|
|
76
|
+
...columns.map((item) => record(item, () => ({ section: item.columnTitle ?? "专栏", path: `/columns/${item.column}/${columnEntryPath(item)}` }))),
|
|
77
|
+
...thoughts.map((item) => record(item, (text) => ({ section: "随想", path: `/thoughts/${item.slug}`, description: excerpt(text, length) }))),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The full-text file: `{ hash, texts, docs }`, where docs is
|
|
83
|
+
* `[[path, textIndex], ...]` in the small index's order. A post that also
|
|
84
|
+
* sits in a column has one body under two paths, so texts are stored once.
|
|
85
|
+
* The hash is of the contents, so the file name changes exactly when they do.
|
|
86
|
+
*/
|
|
87
|
+
export function buildFullIndex({ posts = [], columns = [], thoughts = [] }) {
|
|
88
|
+
const items = [...posts, ...columns, ...thoughts];
|
|
89
|
+
const texts = [];
|
|
90
|
+
const seen = new Map();
|
|
91
|
+
const docs = buildSearchIndex({ posts, columns, thoughts }).map((record, index) => {
|
|
92
|
+
const text = fullText(items[index].content ?? "");
|
|
93
|
+
if (!seen.has(text)) {
|
|
94
|
+
seen.set(text, texts.length);
|
|
95
|
+
texts.push(text);
|
|
96
|
+
}
|
|
97
|
+
return [record.path, seen.get(text)];
|
|
98
|
+
});
|
|
99
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify([texts, docs])).digest("hex").slice(0, 10);
|
|
100
|
+
return { hash, texts, docs, fileName: `search-full.${hash}.json`, json: JSON.stringify({ hash, texts, docs }) };
|
|
101
|
+
}
|
|
102
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substring search over titles, descriptions, tags and body text. Works the
|
|
3
|
+
* same for Chinese and English: every word of the query has to appear
|
|
4
|
+
* somewhere in the entry. Plain indexOf over ~1-2 MB of text stays well under
|
|
5
|
+
* a frame, so it runs on the main thread.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_RANGES_PER_TERM = 12;
|
|
9
|
+
|
|
10
|
+
/** Query words: split on spaces, lowercased, duplicates dropped. */
|
|
11
|
+
export function queryTerms(query) {
|
|
12
|
+
return [...new Set(String(query ?? "").toLowerCase().split(/\s+/u).filter(Boolean))];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function occurrences(haystack, term, limit = MAX_RANGES_PER_TERM) {
|
|
16
|
+
const ranges = [];
|
|
17
|
+
let from = 0;
|
|
18
|
+
while (ranges.length < limit) {
|
|
19
|
+
const at = haystack.indexOf(term, from);
|
|
20
|
+
if (at === -1) break;
|
|
21
|
+
ranges.push([at, at + term.length - 1]);
|
|
22
|
+
from = at + term.length;
|
|
23
|
+
}
|
|
24
|
+
return ranges;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lower = (text) => {
|
|
28
|
+
const value = String(text ?? "");
|
|
29
|
+
const folded = value.toLowerCase();
|
|
30
|
+
// Highlights need positions that line up with the original text.
|
|
31
|
+
return folded.length === value.length ? folded : null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `records` come from search-index.json; `bodies` is a Map of path → full
|
|
36
|
+
* text, or null while only the excerpts are loaded. Results have the shape the
|
|
37
|
+
* header renders: `{ item, matches: [{ key, indices }] }`, where item.content
|
|
38
|
+
* is the body the snippet is cut from.
|
|
39
|
+
*
|
|
40
|
+
* @param {Array<{ title: string; description?: string; path: string; content?: string; tags?: string[] }>} records
|
|
41
|
+
* @param {Map<string, string> | null} [bodies]
|
|
42
|
+
*/
|
|
43
|
+
export function createSearch(records, bodies = null) {
|
|
44
|
+
const docs = records.map((record, order) => {
|
|
45
|
+
const content = bodies?.get(record.path) ?? record.content ?? "";
|
|
46
|
+
const tags = (record.tags ?? []).map((tag) => String(tag).toLowerCase());
|
|
47
|
+
return {
|
|
48
|
+
order,
|
|
49
|
+
item: { ...record, content },
|
|
50
|
+
title: lower(record.title),
|
|
51
|
+
description: lower(record.description),
|
|
52
|
+
body: lower(content),
|
|
53
|
+
tags,
|
|
54
|
+
tagText: tags.join(" "),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function search(query, limit = 6) {
|
|
59
|
+
const terms = queryTerms(query);
|
|
60
|
+
if (!terms.length) return [];
|
|
61
|
+
const hits = [];
|
|
62
|
+
for (const doc of docs) {
|
|
63
|
+
let score = 0;
|
|
64
|
+
const ranges = { title: [], description: [], content: [] };
|
|
65
|
+
let all = true;
|
|
66
|
+
for (const term of terms) {
|
|
67
|
+
const inTitle = doc.title ? occurrences(doc.title, term) : [];
|
|
68
|
+
const inDescription = doc.description ? occurrences(doc.description, term) : [];
|
|
69
|
+
const inBody = doc.body ? occurrences(doc.body, term) : [];
|
|
70
|
+
const tagExact = doc.tags.includes(term);
|
|
71
|
+
const inTags = tagExact || doc.tagText.includes(term);
|
|
72
|
+
if (!inTitle.length && !inDescription.length && !inBody.length && !inTags) {
|
|
73
|
+
all = false;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (inTitle.length) score += 8;
|
|
77
|
+
if (tagExact) score += 6;
|
|
78
|
+
else if (inTags) score += 3;
|
|
79
|
+
if (inDescription.length) score += 3;
|
|
80
|
+
if (inBody.length) score += 1 + inBody.length * 0.1;
|
|
81
|
+
ranges.title.push(...inTitle);
|
|
82
|
+
ranges.description.push(...inDescription);
|
|
83
|
+
ranges.content.push(...inBody);
|
|
84
|
+
}
|
|
85
|
+
if (!all) continue;
|
|
86
|
+
const matches = Object.entries(ranges)
|
|
87
|
+
.filter(([, indices]) => indices.length)
|
|
88
|
+
.map(([key, indices]) => ({ key, indices: indices.sort((a, b) => a[0] - b[0]) }));
|
|
89
|
+
hits.push({ score, order: doc.order, item: doc.item, matches });
|
|
90
|
+
}
|
|
91
|
+
hits.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
92
|
+
return hits.slice(0, limit).map(({ item, matches }) => ({ item, matches }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { full: Boolean(bodies), search };
|
|
96
|
+
}
|