@coywolf/emdash 0.0.0-stage → 0.35.13
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/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link inventory in plugin storage.
|
|
3
|
+
*
|
|
4
|
+
* - links_urls: one row per distinct href (id = hash of the href as written),
|
|
5
|
+
* with its check result, schedule and ignore flag.
|
|
6
|
+
* - links_refs: one row per (entry, href) with the entry's title and the
|
|
7
|
+
* link's anchor text and kinds, so the admin can say where a link is used.
|
|
8
|
+
* How many entries use a link is counted from these rows, never kept as a
|
|
9
|
+
* running total, so overlapping saves and scans can't skew it.
|
|
10
|
+
*
|
|
11
|
+
* Entries are indexed from their latest saved version (the pending draft when
|
|
12
|
+
* a collection uses revisions), which is what an editor fixing a link sees.
|
|
13
|
+
*
|
|
14
|
+
* D1 allows 100 bound parameters per statement, so id lists are chunked to
|
|
15
|
+
* CHUNK; and since database calls count toward a Worker's subrequest limit,
|
|
16
|
+
* long-running work is metered with Ops.
|
|
17
|
+
*/
|
|
18
|
+
import type { CollectionSchemaInfo, PluginContext, StorageCollection } from "emdash";
|
|
19
|
+
|
|
20
|
+
import { PLUGIN_ID } from "../core/features.js";
|
|
21
|
+
import { workerEnv } from "../shared.js";
|
|
22
|
+
import { type IgnoreRule, type LinkStatus, isIgnored } from "./classify.js";
|
|
23
|
+
import { type FoundLink, type LinkKind, bareHost, extractEntryLinks, isInternal, resolveHref } from "./pt.js";
|
|
24
|
+
|
|
25
|
+
export const STORAGE = {
|
|
26
|
+
links_urls: {
|
|
27
|
+
indexes: [
|
|
28
|
+
"status",
|
|
29
|
+
"host",
|
|
30
|
+
"internal",
|
|
31
|
+
"ignored",
|
|
32
|
+
"rank",
|
|
33
|
+
"url",
|
|
34
|
+
"checkedAt",
|
|
35
|
+
"resolved",
|
|
36
|
+
["ignored", "status"],
|
|
37
|
+
["ignored", "nextCheckAt"],
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
links_refs: {
|
|
41
|
+
indexes: ["urlId", "entryKey", "seenAt"],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Sort order for "worst first". */
|
|
46
|
+
export const STATUS_RANK: Record<LinkStatus, number> = { broken: 0, error: 1, blocked: 2, redirect: 3, unchecked: 4, ok: 5 };
|
|
47
|
+
|
|
48
|
+
export interface UrlRow {
|
|
49
|
+
url: string;
|
|
50
|
+
/** Absolute URL that gets checked; null when the href can't be resolved. */
|
|
51
|
+
resolved: string | null;
|
|
52
|
+
host: string;
|
|
53
|
+
internal: boolean;
|
|
54
|
+
ignored: boolean;
|
|
55
|
+
status: LinkStatus;
|
|
56
|
+
rank: number;
|
|
57
|
+
code: number | null;
|
|
58
|
+
finalUrl: string | null;
|
|
59
|
+
chain: Array<{ url: string; code: number }>;
|
|
60
|
+
note: string;
|
|
61
|
+
checkedAt: string | null;
|
|
62
|
+
/** "" until first checked, so unchecked links sort first. */
|
|
63
|
+
nextCheckAt: string;
|
|
64
|
+
firstSeen: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RefRow {
|
|
68
|
+
urlId: string;
|
|
69
|
+
entryKey: string;
|
|
70
|
+
collection: string;
|
|
71
|
+
entryId: string;
|
|
72
|
+
title: string;
|
|
73
|
+
entryStatus: string;
|
|
74
|
+
kinds: LinkKind[];
|
|
75
|
+
anchors: string[];
|
|
76
|
+
count: number;
|
|
77
|
+
/** When the row was last written (the scan no longer rewrites unchanged rows, so nothing reads it). */
|
|
78
|
+
seenAt: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const IGNORES_KEY = "links:ignores";
|
|
82
|
+
export const SCAN_KEY = "links:scan";
|
|
83
|
+
const LEASE_KEY = "links:scan-lease";
|
|
84
|
+
const COUNTS_KEY = "links:counts";
|
|
85
|
+
|
|
86
|
+
/** Field types that can hold links. */
|
|
87
|
+
const LINK_FIELD_TYPES = new Set(["portableText", "blocks", "repeater", "url"]);
|
|
88
|
+
|
|
89
|
+
export function urls(ctx: PluginContext): StorageCollection<UrlRow> {
|
|
90
|
+
return ctx.storage.links_urls as StorageCollection<UrlRow>;
|
|
91
|
+
}
|
|
92
|
+
export function refs(ctx: PluginContext): StorageCollection<RefRow> {
|
|
93
|
+
return ctx.storage.links_refs as StorageCollection<RefRow>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function urlId(href: string): Promise<string> {
|
|
97
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(href)));
|
|
98
|
+
return [...bytes.slice(0, 12)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Metering and chunking ────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** Counts database statements (and requests) against a limit and a wall-clock deadline. */
|
|
104
|
+
export class Ops {
|
|
105
|
+
used = 0;
|
|
106
|
+
readonly limit: number;
|
|
107
|
+
readonly deadline: number;
|
|
108
|
+
constructor(limit = Number.POSITIVE_INFINITY, deadline = Number.POSITIVE_INFINITY) {
|
|
109
|
+
this.limit = limit;
|
|
110
|
+
this.deadline = deadline;
|
|
111
|
+
}
|
|
112
|
+
spend(n = 1): void {
|
|
113
|
+
this.used += n;
|
|
114
|
+
}
|
|
115
|
+
get left(): number {
|
|
116
|
+
return this.limit - this.used;
|
|
117
|
+
}
|
|
118
|
+
/** Whether there's room for `reserve` more operations before the limit and deadline. */
|
|
119
|
+
has(reserve = 1): boolean {
|
|
120
|
+
return this.used + reserve <= this.limit && Date.now() < this.deadline;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Ids per statement: D1 binds at most 100 parameters, and storage adds its own. */
|
|
125
|
+
export const CHUNK = 90;
|
|
126
|
+
|
|
127
|
+
export function chunked<T>(items: T[], size = CHUNK): T[][] {
|
|
128
|
+
const out: T[][] = [];
|
|
129
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function getMany<T>(collection: StorageCollection<T>, ids: string[], ops?: Ops): Promise<Map<string, T>> {
|
|
134
|
+
const out = new Map<string, T>();
|
|
135
|
+
for (const part of chunked([...new Set(ids)])) {
|
|
136
|
+
ops?.spend();
|
|
137
|
+
for (const [id, data] of await collection.getMany(part)) out.set(id, data);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function deleteMany<T>(collection: StorageCollection<T>, ids: string[], ops?: Ops): Promise<number> {
|
|
143
|
+
let deleted = 0;
|
|
144
|
+
for (const part of chunked([...new Set(ids)])) {
|
|
145
|
+
ops?.spend();
|
|
146
|
+
deleted += await collection.deleteMany(part);
|
|
147
|
+
}
|
|
148
|
+
return deleted;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function putMany<T>(collection: StorageCollection<T>, items: Array<{ id: string; data: T }>, ops?: Ops): Promise<void> {
|
|
152
|
+
for (const part of chunked(items)) {
|
|
153
|
+
ops?.spend(part.length); // One statement per row.
|
|
154
|
+
await collection.putMany(part);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every row matching `where`, page by page. */
|
|
159
|
+
export async function queryAll<T>(
|
|
160
|
+
collection: StorageCollection<T>,
|
|
161
|
+
where?: Record<string, unknown>,
|
|
162
|
+
options: { ops?: Ops; max?: number } = {},
|
|
163
|
+
): Promise<Array<{ id: string; data: T }>> {
|
|
164
|
+
const max = options.max ?? 50_000;
|
|
165
|
+
const out: Array<{ id: string; data: T }> = [];
|
|
166
|
+
let cursor: string | undefined;
|
|
167
|
+
do {
|
|
168
|
+
options.ops?.spend();
|
|
169
|
+
// biome-ignore lint/suspicious/noExplicitAny: WhereClause is EmDash's.
|
|
170
|
+
const page = await collection.query({ where: where as any, limit: 100, cursor });
|
|
171
|
+
out.push(...page.items);
|
|
172
|
+
cursor = page.hasMore ? page.cursor : undefined;
|
|
173
|
+
} while (cursor && out.length < max);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* SQL of refsWhere: one indexed field, compared for equality, with no ORDER BY.
|
|
179
|
+
* Storage queries always sort (by created_at, id), and for that SQLite picks
|
|
180
|
+
* the generic idx_plugin_storage_list index and scans every links_refs row of
|
|
181
|
+
* the site; without the sort it uses the field's own expression index
|
|
182
|
+
* (idx_plugin_coywolf-pack_links_refs_<field>), so a lookup reads only its rows.
|
|
183
|
+
*/
|
|
184
|
+
export const refsWhereSql = (field: "entryKey" | "urlId") =>
|
|
185
|
+
`SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND json_extract(data, '$.${field}') = ?`;
|
|
186
|
+
|
|
187
|
+
/** The site's D1 binding, when there is one (none in tests or on other databases). */
|
|
188
|
+
async function d1(): Promise<D1Database | undefined> {
|
|
189
|
+
const db = ((await workerEnv().catch(() => ({}))) as Record<string, unknown>).DB as D1Database | undefined;
|
|
190
|
+
return db?.prepare ? db : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Reference rows whose `field` equals `value`, in any order, read straight from
|
|
195
|
+
* D1 (one statement, see refsWhereSql). These lookups run once per entry
|
|
196
|
+
* indexed (hundreds per weekly scan), so they must use the index. Without a D1
|
|
197
|
+
* binding (tests, other databases) it falls back to the storage API.
|
|
198
|
+
*/
|
|
199
|
+
export async function refsWhere(ctx: PluginContext, field: "entryKey" | "urlId", value: string, ops?: Ops): Promise<Array<{ id: string; data: RefRow }>> {
|
|
200
|
+
const db = await d1();
|
|
201
|
+
if (!db) return queryAll(refs(ctx), { [field]: value }, { ops });
|
|
202
|
+
ops?.spend();
|
|
203
|
+
const { results } = await db.prepare(refsWhereSql(field)).bind(PLUGIN_ID, "links_refs", value).all<{ id: string; data: string }>();
|
|
204
|
+
return (results ?? []).map((row) => ({ id: row.id, data: JSON.parse(row.data) as RefRow }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Rows whose `field` is any of `values`, chunked so each statement stays under D1's parameter limit. */
|
|
208
|
+
export async function queryIn<T>(
|
|
209
|
+
collection: StorageCollection<T>,
|
|
210
|
+
field: string,
|
|
211
|
+
values: string[],
|
|
212
|
+
ops?: Ops,
|
|
213
|
+
): Promise<Array<{ id: string; data: T }>> {
|
|
214
|
+
const out: Array<{ id: string; data: T }> = [];
|
|
215
|
+
for (const part of chunked([...new Set(values)])) out.push(...(await queryAll(collection, { [field]: { in: part } }, { ops })));
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function getIgnores(ctx: PluginContext): Promise<IgnoreRule[]> {
|
|
220
|
+
return (await ctx.kv.get<IgnoreRule[]>(IGNORES_KEY)) ?? [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The site URL from EmDash (astro config `site`, or the Site URL setting). Empty when neither is set. */
|
|
224
|
+
export function siteUrl(ctx: PluginContext): string {
|
|
225
|
+
return ctx.site?.url ?? "";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Summary counts (cached in KV) ────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export type Counts = Record<LinkStatus | "all" | "ignored" | "internal" | "external", number>;
|
|
231
|
+
|
|
232
|
+
export async function getCounts(ctx: PluginContext, maxAgeMs = 10 * 60_000): Promise<Counts> {
|
|
233
|
+
const cached = await ctx.kv.get<{ at: number; counts: Counts }>(COUNTS_KEY);
|
|
234
|
+
if (cached && Date.now() - cached.at < maxAgeMs) return cached.counts;
|
|
235
|
+
const statuses = Object.keys(STATUS_RANK) as LinkStatus[];
|
|
236
|
+
const counts = { all: 0 } as Counts;
|
|
237
|
+
for (const status of statuses) {
|
|
238
|
+
counts[status] = await urls(ctx).count({ ignored: false, status });
|
|
239
|
+
counts.all += counts[status];
|
|
240
|
+
}
|
|
241
|
+
counts.ignored = await urls(ctx).count({ ignored: true });
|
|
242
|
+
counts.internal = await urls(ctx).count({ ignored: false, internal: true });
|
|
243
|
+
counts.external = counts.all - counts.internal;
|
|
244
|
+
await ctx.kv.set(COUNTS_KEY, { at: Date.now(), counts });
|
|
245
|
+
return counts;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function invalidateCounts(ctx: PluginContext): Promise<void> {
|
|
249
|
+
await ctx.kv.delete(COUNTS_KEY);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Schema ───────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export interface CollectionLinkInfo {
|
|
255
|
+
slug: string;
|
|
256
|
+
label: string;
|
|
257
|
+
titleField: string | null;
|
|
258
|
+
revisions: boolean;
|
|
259
|
+
fields: Array<{ slug: string; type: string }>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let schemaCache: { at: number; map: Map<string, CollectionLinkInfo> } | null = null;
|
|
263
|
+
|
|
264
|
+
/** Forget the cached collections, so the next linkCollections reads the schema again. */
|
|
265
|
+
export function resetLinkCollections(): void {
|
|
266
|
+
schemaCache = null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Collections with link-bearing fields (cached per isolate for a minute). */
|
|
270
|
+
export async function linkCollections(ctx: PluginContext): Promise<Map<string, CollectionLinkInfo>> {
|
|
271
|
+
if (schemaCache && Date.now() - schemaCache.at < 60_000) return schemaCache.map;
|
|
272
|
+
const map = new Map<string, CollectionLinkInfo>();
|
|
273
|
+
const collections: CollectionSchemaInfo[] = (await ctx.schema?.listCollections()) ?? [];
|
|
274
|
+
for (const c of collections) {
|
|
275
|
+
const fields = c.fields.filter((f) => LINK_FIELD_TYPES.has(f.type)).map((f) => ({ slug: f.slug, type: f.type }));
|
|
276
|
+
if (!fields.length) continue;
|
|
277
|
+
map.set(c.slug, {
|
|
278
|
+
slug: c.slug,
|
|
279
|
+
label: c.label,
|
|
280
|
+
titleField: c.titleField,
|
|
281
|
+
revisions: c.supports.includes("revisions"),
|
|
282
|
+
fields,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
schemaCache = { at: Date.now(), map };
|
|
286
|
+
return map;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Indexing ─────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export const entryKey = (collection: string, id: string) => `${collection}/${id}`;
|
|
292
|
+
|
|
293
|
+
function entryTitle(info: CollectionLinkInfo, item: { data: Record<string, unknown>; slug?: string | null; id: string }): string {
|
|
294
|
+
const candidates = [info.titleField ? item.data[info.titleField] : undefined, item.data.title, item.data.name, item.slug, item.id];
|
|
295
|
+
const title = candidates.find((v) => typeof v === "string" && v.trim()) as string;
|
|
296
|
+
return title.trim().slice(0, 200);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The entry's latest saved data: its pending draft when it has one. */
|
|
300
|
+
export async function latestData(
|
|
301
|
+
ctx: PluginContext,
|
|
302
|
+
collection: string,
|
|
303
|
+
item: { id: string; data: Record<string, unknown>; draftRevisionId?: string | null },
|
|
304
|
+
ops?: Ops,
|
|
305
|
+
): Promise<Record<string, unknown>> {
|
|
306
|
+
if (item.draftRevisionId && ctx.content?.getRevision) {
|
|
307
|
+
try {
|
|
308
|
+
ops?.spend();
|
|
309
|
+
const draft = await ctx.content.getRevision(collection, item.id, item.draftRevisionId);
|
|
310
|
+
if (draft?.data) return { ...item.data, ...draft.data };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
ctx.log.warn("links: could not read draft revision", { collection, id: item.id, error: String(error) });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return item.data;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function group(found: FoundLink[]): Map<string, { kinds: Set<LinkKind>; anchors: Set<string>; count: number }> {
|
|
319
|
+
const map = new Map<string, { kinds: Set<LinkKind>; anchors: Set<string>; count: number }>();
|
|
320
|
+
for (const link of found) {
|
|
321
|
+
const g = map.get(link.href) ?? { kinds: new Set(), anchors: new Set(), count: 0 };
|
|
322
|
+
g.kinds.add(link.kind);
|
|
323
|
+
if (link.anchor && g.anchors.size < 3) g.anchors.add(link.anchor);
|
|
324
|
+
g.count++;
|
|
325
|
+
map.set(link.href, g);
|
|
326
|
+
}
|
|
327
|
+
return map;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Where an href points, given the site URL. */
|
|
331
|
+
export function locate(href: string, base: string, ignores: IgnoreRule[]): Pick<UrlRow, "resolved" | "host" | "internal" | "ignored"> {
|
|
332
|
+
const resolved = resolveHref(href, base);
|
|
333
|
+
return {
|
|
334
|
+
resolved: resolved?.href ?? null,
|
|
335
|
+
host: resolved ? bareHost(resolved.hostname) : "",
|
|
336
|
+
internal: isInternal(href, base),
|
|
337
|
+
ignored: resolved ? isIgnored(ignores, resolved.href) : false,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function newUrlRow(href: string, base: string, ignores: IgnoreRule[], now: string): UrlRow {
|
|
342
|
+
return {
|
|
343
|
+
url: href,
|
|
344
|
+
...locate(href, base, ignores),
|
|
345
|
+
status: "unchecked",
|
|
346
|
+
rank: STATUS_RANK.unchecked,
|
|
347
|
+
code: null,
|
|
348
|
+
finalUrl: null,
|
|
349
|
+
chain: [],
|
|
350
|
+
note: "",
|
|
351
|
+
checkedAt: null,
|
|
352
|
+
nextCheckAt: "",
|
|
353
|
+
firstSeen: now,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Delete URL rows no entry uses any more. */
|
|
358
|
+
async function dropOrphans(ctx: PluginContext, ids: string[], ops?: Ops): Promise<void> {
|
|
359
|
+
const orphans: string[] = [];
|
|
360
|
+
for (const id of new Set(ids)) {
|
|
361
|
+
ops?.spend();
|
|
362
|
+
if ((await refs(ctx).count({ urlId: id })) === 0) orphans.push(id);
|
|
363
|
+
}
|
|
364
|
+
if (orphans.length) await deleteMany(urls(ctx), orphans, ops);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const sameRef = (a: RefRow, b: RefRow) =>
|
|
368
|
+
a.title === b.title &&
|
|
369
|
+
a.entryStatus === b.entryStatus &&
|
|
370
|
+
a.count === b.count &&
|
|
371
|
+
a.kinds.join() === b.kinds.join() &&
|
|
372
|
+
a.anchors.join("\u0000") === b.anchors.join("\u0000");
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Replace an entry's link references with the links in `data`. Returns the
|
|
376
|
+
* number of distinct links, or -1 when the site URL is unknown (nothing is
|
|
377
|
+
* indexed then, since internal links couldn't be told apart).
|
|
378
|
+
* Only new and changed references are written, so re-indexing an unchanged
|
|
379
|
+
* entry (as every scan does) reads its rows and writes nothing.
|
|
380
|
+
*/
|
|
381
|
+
export async function indexEntry(
|
|
382
|
+
ctx: PluginContext,
|
|
383
|
+
collection: string,
|
|
384
|
+
item: { id: string; slug?: string | null; status?: string; data: Record<string, unknown> },
|
|
385
|
+
options: { ignores?: IgnoreRule[]; ops?: Ops } = {},
|
|
386
|
+
): Promise<number> {
|
|
387
|
+
const base = siteUrl(ctx);
|
|
388
|
+
if (!base) {
|
|
389
|
+
ctx.log.warn("links: the site URL isn't set (Settings → General or astro.config `site`), so links can't be indexed yet.");
|
|
390
|
+
return -1;
|
|
391
|
+
}
|
|
392
|
+
const ops = options.ops;
|
|
393
|
+
const info = (await linkCollections(ctx)).get(collection);
|
|
394
|
+
if (!info) return removeEntry(ctx, collection, item.id, ops).then(() => 0);
|
|
395
|
+
const key = entryKey(collection, item.id);
|
|
396
|
+
const grouped = group(extractEntryLinks(item.data, info.fields));
|
|
397
|
+
const now = new Date().toISOString();
|
|
398
|
+
const title = entryTitle(info, item);
|
|
399
|
+
|
|
400
|
+
const next = new Map<string, RefRow>();
|
|
401
|
+
const hrefById = new Map<string, string>();
|
|
402
|
+
for (const [href, g] of grouped) {
|
|
403
|
+
const id = await urlId(href);
|
|
404
|
+
hrefById.set(id, href);
|
|
405
|
+
next.set(id, {
|
|
406
|
+
urlId: id,
|
|
407
|
+
entryKey: key,
|
|
408
|
+
collection,
|
|
409
|
+
entryId: item.id,
|
|
410
|
+
title,
|
|
411
|
+
entryStatus: item.status ?? "",
|
|
412
|
+
kinds: [...g.kinds].sort(),
|
|
413
|
+
anchors: [...g.anchors],
|
|
414
|
+
count: g.count,
|
|
415
|
+
seenAt: now,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const existing = new Map((await refsWhere(ctx, "entryKey", key, ops)).map((r) => [r.data.urlId, r.data]));
|
|
420
|
+
const removed = [...existing.keys()].filter((id) => !next.has(id));
|
|
421
|
+
const added = [...next.keys()].filter((id) => !existing.has(id));
|
|
422
|
+
const changed = [...next].filter(([id, row]) => {
|
|
423
|
+
const old = existing.get(id);
|
|
424
|
+
return !old || !sameRef(old, row);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (removed.length) await deleteMany(refs(ctx), removed.map((id) => `${key}|${id}`), ops);
|
|
428
|
+
if (changed.length) await putMany(refs(ctx), changed.map(([id, row]) => ({ id: `${key}|${id}`, data: row })), ops);
|
|
429
|
+
|
|
430
|
+
if (added.length) {
|
|
431
|
+
const have = await getMany(urls(ctx), added, ops);
|
|
432
|
+
const missing = added.filter((id) => !have.has(id));
|
|
433
|
+
if (missing.length) {
|
|
434
|
+
const ignores = options.ignores ?? (await getIgnores(ctx));
|
|
435
|
+
await putMany(
|
|
436
|
+
urls(ctx),
|
|
437
|
+
missing.map((id) => ({ id, data: newUrlRow(hrefById.get(id) as string, base, ignores, now) })),
|
|
438
|
+
ops,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (removed.length) await dropOrphans(ctx, removed, ops);
|
|
443
|
+
return next.size;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function removeEntry(ctx: PluginContext, collection: string, id: string, ops?: Ops): Promise<void> {
|
|
447
|
+
const key = entryKey(collection, id);
|
|
448
|
+
const existing = await refsWhere(ctx, "entryKey", key, ops);
|
|
449
|
+
if (!existing.length) return;
|
|
450
|
+
await deleteMany(
|
|
451
|
+
refs(ctx),
|
|
452
|
+
existing.map((r) => r.id),
|
|
453
|
+
ops,
|
|
454
|
+
);
|
|
455
|
+
await dropOrphans(
|
|
456
|
+
ctx,
|
|
457
|
+
existing.map((r) => r.data.urlId),
|
|
458
|
+
ops,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Fill in where links point for rows saved without a usable site URL. */
|
|
463
|
+
export async function fixUnresolved(ctx: PluginContext, ops?: Ops): Promise<number> {
|
|
464
|
+
const base = siteUrl(ctx);
|
|
465
|
+
if (!base) return 0;
|
|
466
|
+
ops?.spend();
|
|
467
|
+
const page = await urls(ctx).query({ where: { resolved: null }, limit: 100 });
|
|
468
|
+
const ignores = await getIgnores(ctx);
|
|
469
|
+
const updates = page.items
|
|
470
|
+
.map(({ id, data }) => ({ id, data: { ...data, ...locate(data.url, base, ignores) } }))
|
|
471
|
+
.filter((u) => u.data.resolved !== null);
|
|
472
|
+
if (updates.length) await putMany(urls(ctx), updates, ops);
|
|
473
|
+
return updates.length;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── Full scan ────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
export interface ScanState {
|
|
479
|
+
status: "idle" | "running";
|
|
480
|
+
phase: "index" | "cleanup";
|
|
481
|
+
startedAt: string | null;
|
|
482
|
+
finishedAt: string | null;
|
|
483
|
+
collections: string[];
|
|
484
|
+
index: number;
|
|
485
|
+
cursor: string | null;
|
|
486
|
+
/** Entries of the current page already indexed. */
|
|
487
|
+
offset: number;
|
|
488
|
+
processed: number;
|
|
489
|
+
error?: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export const IDLE_SCAN: ScanState = {
|
|
493
|
+
status: "idle",
|
|
494
|
+
phase: "index",
|
|
495
|
+
startedAt: null,
|
|
496
|
+
finishedAt: null,
|
|
497
|
+
collections: [],
|
|
498
|
+
index: 0,
|
|
499
|
+
cursor: null,
|
|
500
|
+
offset: 0,
|
|
501
|
+
processed: 0,
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
export async function getScan(ctx: PluginContext): Promise<ScanState | null> {
|
|
505
|
+
const state = await ctx.kv.get<ScanState>(SCAN_KEY);
|
|
506
|
+
return state ? { ...IDLE_SCAN, ...state } : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function startScan(ctx: PluginContext): Promise<ScanState> {
|
|
510
|
+
const state: ScanState = {
|
|
511
|
+
...IDLE_SCAN,
|
|
512
|
+
status: "running",
|
|
513
|
+
startedAt: new Date().toISOString(),
|
|
514
|
+
collections: [...(await linkCollections(ctx)).keys()],
|
|
515
|
+
};
|
|
516
|
+
await ctx.kv.set(SCAN_KEY, state);
|
|
517
|
+
return state;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** One scan at a time across cron and admin requests: a lease with an owner and an expiry, taken with compare-and-set. */
|
|
521
|
+
async function acquireLease(ctx: PluginContext, owner: string, ttlMs: number): Promise<boolean> {
|
|
522
|
+
const current = await ctx.kv.getVersioned<{ owner: string; expires: number }>(LEASE_KEY);
|
|
523
|
+
if (current?.value && current.value.owner !== owner && current.value.expires > Date.now()) return false;
|
|
524
|
+
const result = await ctx.kv.compareAndSet(LEASE_KEY, current?.revision ?? null, { owner, expires: Date.now() + ttlMs });
|
|
525
|
+
return result.applied;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function releaseLease(ctx: PluginContext, owner: string): Promise<void> {
|
|
529
|
+
const current = await ctx.kv.getVersioned<{ owner: string; expires: number }>(LEASE_KEY);
|
|
530
|
+
if (current?.value?.owner === owner) await ctx.kv.compareAndDelete(LEASE_KEY, current.revision);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Collection slugs as EmDash allows them (its tables are `ec_<slug>`). */
|
|
534
|
+
const COLLECTION_SLUG = /^[a-z][a-z0-9_]*$/;
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Entries (keys) that have references but that a scan wouldn't visit now:
|
|
538
|
+
* deleted or trashed, or in a collection that has no link fields any more.
|
|
539
|
+
* With D1 this is one statement over the entryKey index plus one per 90
|
|
540
|
+
* entries of each collection; without it, the storage API and content.get.
|
|
541
|
+
*/
|
|
542
|
+
export async function staleEntries(ctx: PluginContext, ops?: Ops): Promise<string[]> {
|
|
543
|
+
const db = await d1();
|
|
544
|
+
let keys: string[];
|
|
545
|
+
if (db) {
|
|
546
|
+
ops?.spend();
|
|
547
|
+
const { results } = await db.prepare(referencedEntriesSql).bind(PLUGIN_ID, "links_refs").all<{ k: string | null }>();
|
|
548
|
+
keys = (results ?? []).map((r) => r.k).filter((k): k is string => typeof k === "string");
|
|
549
|
+
} else {
|
|
550
|
+
keys = [...new Set((await queryAll(refs(ctx), undefined, { ops })).map((r) => r.data.entryKey))];
|
|
551
|
+
}
|
|
552
|
+
if (!keys.length) return [];
|
|
553
|
+
const collections = await linkCollections(ctx);
|
|
554
|
+
// An empty answer can't be told from a failed one (schema access missing, or a listing that came
|
|
555
|
+
// back empty), and acting on it would delete every reference. Keep them; the next scan tries again.
|
|
556
|
+
if (!collections.size) {
|
|
557
|
+
ctx.log.warn("links: no collections with link fields were listed, so no references were cleaned up");
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
const get = ctx.content?.get;
|
|
561
|
+
if (!db && !get) {
|
|
562
|
+
ctx.log.warn("links: content can't be read, so no references were cleaned up");
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
const byCollection = new Map<string, string[]>();
|
|
566
|
+
const stale: string[] = [];
|
|
567
|
+
for (const key of keys) {
|
|
568
|
+
const slash = key.indexOf("/");
|
|
569
|
+
const collection = key.slice(0, slash);
|
|
570
|
+
if (slash < 1 || !collections.has(collection) || !COLLECTION_SLUG.test(collection)) {
|
|
571
|
+
stale.push(key);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const ids = byCollection.get(collection);
|
|
575
|
+
if (ids) ids.push(key.slice(slash + 1));
|
|
576
|
+
else byCollection.set(collection, [key.slice(slash + 1)]);
|
|
577
|
+
}
|
|
578
|
+
for (const [collection, ids] of byCollection) {
|
|
579
|
+
try {
|
|
580
|
+
const live = new Set<string>();
|
|
581
|
+
for (const part of chunked(ids)) {
|
|
582
|
+
ops?.spend(db ? 1 : part.length);
|
|
583
|
+
if (db) {
|
|
584
|
+
const sql = `SELECT id FROM "ec_${collection}" WHERE deleted_at IS NULL AND id IN (${part.map(() => "?").join(", ")})`;
|
|
585
|
+
const { results } = await db
|
|
586
|
+
.prepare(sql)
|
|
587
|
+
.bind(...part)
|
|
588
|
+
.all<{ id: string }>();
|
|
589
|
+
for (const row of results ?? []) live.add(row.id);
|
|
590
|
+
} else if (get) {
|
|
591
|
+
for (const id of part) if (await get.call(ctx.content, collection, id)) live.add(id);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
for (const id of ids) if (!live.has(id)) stale.push(entryKey(collection, id));
|
|
595
|
+
} catch (error) {
|
|
596
|
+
// Keep references when entries can't be looked up: a later scan tries again.
|
|
597
|
+
ctx.log.warn("links: could not check entries for cleanup", { collection, error: String(error) });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return stale;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Each entry with references, once, read from the entryKey expression index (no ORDER BY, see refsWhereSql). */
|
|
604
|
+
export const referencedEntriesSql = `SELECT DISTINCT json_extract(data, '$.entryKey') AS k FROM _plugin_storage WHERE plugin_id = ? AND collection = ?`;
|
|
605
|
+
|
|
606
|
+
/** Statements an entry may take; a step stops before starting one it can't afford. */
|
|
607
|
+
const ENTRY_RESERVE = 25;
|
|
608
|
+
const PAGE = 10;
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Index entries within `ops` (statements and deadline), writing only references
|
|
612
|
+
* that are new or changed, then drop references of entries the scan can't visit
|
|
613
|
+
* (staleEntries). Returns the state, plus `busy` when another scan step
|
|
614
|
+
* holds the lease.
|
|
615
|
+
*/
|
|
616
|
+
export async function scanStep(ctx: PluginContext, ops: Ops): Promise<ScanState & { busy?: boolean }> {
|
|
617
|
+
const owner = crypto.randomUUID();
|
|
618
|
+
const leaseMs = Math.max(30_000, ops.deadline - Date.now() + 30_000);
|
|
619
|
+
if (!(await acquireLease(ctx, owner, Number.isFinite(leaseMs) ? leaseMs : 120_000))) {
|
|
620
|
+
return { ...((await getScan(ctx)) ?? IDLE_SCAN), busy: true };
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
const state = (await getScan(ctx)) ?? { ...IDLE_SCAN };
|
|
624
|
+
if (state.status !== "running" || !ctx.content) return state;
|
|
625
|
+
if (!siteUrl(ctx)) {
|
|
626
|
+
state.error = "The site URL isn't set (Settings → General, or `site` in astro.config), so internal links can't be recognized.";
|
|
627
|
+
await ctx.kv.set(SCAN_KEY, state);
|
|
628
|
+
return state;
|
|
629
|
+
}
|
|
630
|
+
delete state.error;
|
|
631
|
+
const ignores = await getIgnores(ctx);
|
|
632
|
+
ops.spend(4);
|
|
633
|
+
|
|
634
|
+
while (state.phase === "index" && state.index < state.collections.length && ops.has(ENTRY_RESERVE)) {
|
|
635
|
+
const collection = state.collections[state.index];
|
|
636
|
+
let page: Awaited<ReturnType<NonNullable<PluginContext["content"]>["list"]>>;
|
|
637
|
+
try {
|
|
638
|
+
ops.spend(2);
|
|
639
|
+
page = await ctx.content.list(collection, { limit: PAGE, cursor: state.cursor ?? undefined });
|
|
640
|
+
} catch (error) {
|
|
641
|
+
// Skip a collection that can't be listed (e.g. removed mid-scan) instead of retrying it forever.
|
|
642
|
+
ctx.log.warn("links: could not list collection", { collection, error: String(error) });
|
|
643
|
+
Object.assign(state, { index: state.index + 1, cursor: null, offset: 0 });
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
let i = state.offset;
|
|
647
|
+
for (; i < page.items.length && ops.has(ENTRY_RESERVE); i++) {
|
|
648
|
+
const item = page.items[i];
|
|
649
|
+
const data = await latestData(ctx, collection, item, ops);
|
|
650
|
+
await indexEntry(ctx, collection, { ...item, data }, { ignores, ops });
|
|
651
|
+
state.processed++;
|
|
652
|
+
}
|
|
653
|
+
if (i < page.items.length) {
|
|
654
|
+
state.offset = i; // Out of budget mid-page: resume here.
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
state.offset = 0;
|
|
658
|
+
if (page.hasMore && page.cursor) state.cursor = page.cursor;
|
|
659
|
+
else Object.assign(state, { index: state.index + 1, cursor: null });
|
|
660
|
+
}
|
|
661
|
+
if (state.phase === "index" && state.index >= state.collections.length) state.phase = "cleanup";
|
|
662
|
+
|
|
663
|
+
// Entries the scan visited dropped their removed links as they were indexed. What's left is
|
|
664
|
+
// references from entries it couldn't visit: deleted or trashed, or in a collection without
|
|
665
|
+
// link fields now. Recomputed each step (a few statements), so cleanup resumes without state.
|
|
666
|
+
if (state.phase === "cleanup" && ops.has(ENTRY_RESERVE)) {
|
|
667
|
+
const stale = await staleEntries(ctx, ops);
|
|
668
|
+
let i = 0;
|
|
669
|
+
for (; i < stale.length && ops.has(ENTRY_RESERVE); i++) {
|
|
670
|
+
const slash = stale[i].indexOf("/");
|
|
671
|
+
await removeEntry(ctx, stale[i].slice(0, slash), stale[i].slice(slash + 1), ops);
|
|
672
|
+
}
|
|
673
|
+
if (i === stale.length) {
|
|
674
|
+
Object.assign(state, { status: "idle", phase: "index", finishedAt: new Date().toISOString(), cursor: null, offset: 0 });
|
|
675
|
+
ctx.log.info("links: scan finished", { entries: state.processed, removed: stale.length });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
await ctx.kv.set(SCAN_KEY, state);
|
|
679
|
+
await invalidateCounts(ctx);
|
|
680
|
+
return state;
|
|
681
|
+
} catch (error) {
|
|
682
|
+
const state = (await getScan(ctx)) ?? { ...IDLE_SCAN };
|
|
683
|
+
state.error = String(error).slice(0, 300);
|
|
684
|
+
await ctx.kv.set(SCAN_KEY, state);
|
|
685
|
+
throw error;
|
|
686
|
+
} finally {
|
|
687
|
+
await releaseLease(ctx, owner);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── Ignore flags ─────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
/** Recompute every URL's ignore flag after the rules change. */
|
|
694
|
+
export async function reapplyIgnores(ctx: PluginContext, rules: IgnoreRule[]): Promise<number> {
|
|
695
|
+
const rows = await queryAll(urls(ctx));
|
|
696
|
+
const updates: Array<{ id: string; data: UrlRow }> = [];
|
|
697
|
+
for (const row of rows) {
|
|
698
|
+
const ignored = row.data.resolved ? isIgnored(rules, row.data.resolved) : false;
|
|
699
|
+
if (ignored !== row.data.ignored) updates.push({ id: row.id, data: { ...row.data, ignored } });
|
|
700
|
+
}
|
|
701
|
+
await putMany(urls(ctx), updates);
|
|
702
|
+
await invalidateCounts(ctx);
|
|
703
|
+
return updates.length;
|
|
704
|
+
}
|