@coywolf/emdash 0.0.0-stage → 0.35.14
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 +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -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.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/routes.ts +359 -0
- package/src/ai/store.ts +301 -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.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -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.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.ts +60 -0
- package/src/files/export.ts +44 -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.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.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/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.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.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.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.ts +362 -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.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,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link Manager: routes, hooks and scheduled jobs.
|
|
3
|
+
*/
|
|
4
|
+
import type { PluginContext } from "emdash";
|
|
5
|
+
import { PluginRouteError, definePluginRoute } from "emdash";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { ctxFeatures, requireFeature } from "../core/features.js";
|
|
9
|
+
import { parseInput, workerEnv } from "../shared.js";
|
|
10
|
+
import { BudgetExhausted, type Budget, checkUrl } from "./check.js";
|
|
11
|
+
import { type IgnoreRule, IgnoreRuleError, type LinkStatus, nextCheckAt, normalizeIgnore } from "./classify.js";
|
|
12
|
+
import { type LinkEdit, isAllowedTarget, transformEntry } from "./pt.js";
|
|
13
|
+
import {
|
|
14
|
+
IDLE_SCAN,
|
|
15
|
+
IGNORES_KEY,
|
|
16
|
+
Ops,
|
|
17
|
+
type RefRow,
|
|
18
|
+
STATUS_RANK,
|
|
19
|
+
type UrlRow,
|
|
20
|
+
fixUnresolved,
|
|
21
|
+
getCounts,
|
|
22
|
+
getIgnores,
|
|
23
|
+
getMany,
|
|
24
|
+
getScan,
|
|
25
|
+
indexEntry,
|
|
26
|
+
invalidateCounts,
|
|
27
|
+
latestData,
|
|
28
|
+
linkCollections,
|
|
29
|
+
putMany,
|
|
30
|
+
queryIn,
|
|
31
|
+
reapplyIgnores,
|
|
32
|
+
refs,
|
|
33
|
+
removeEntry,
|
|
34
|
+
scanStep,
|
|
35
|
+
startScan,
|
|
36
|
+
urls,
|
|
37
|
+
} from "./store.js";
|
|
38
|
+
|
|
39
|
+
/** No build-time options yet; settings live on the Link Manager page. */
|
|
40
|
+
// biome-ignore lint/complexity/noBannedTypes: reserved for future options.
|
|
41
|
+
export type LinksOptions = {};
|
|
42
|
+
|
|
43
|
+
export const LINKS_SCAN_TASK = "links-scan";
|
|
44
|
+
export const LINKS_CHECK_TASK = "links-check";
|
|
45
|
+
const PAGE_SIZE = 50;
|
|
46
|
+
const CONCURRENCY = 6;
|
|
47
|
+
const STATUSES = Object.keys(STATUS_RANK) as LinkStatus[];
|
|
48
|
+
|
|
49
|
+
/** Per admin request: statements (D1 calls count as subrequests) and wall time. */
|
|
50
|
+
const REQUEST_OPS = 150;
|
|
51
|
+
const REQUEST_MS = 25_000;
|
|
52
|
+
/** Per scheduled scan run. */
|
|
53
|
+
const SCAN_OPS = 200;
|
|
54
|
+
const SCAN_MS = 60_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Link checking settings, edited on the Link Manager page. Not in the
|
|
58
|
+
* plugin's settingsSchema (that holds secrets only), so reads supply defaults.
|
|
59
|
+
*/
|
|
60
|
+
export const LINKS_DEFAULTS = { checkBudget: 40, checkInternal: true, userAgent: "", frequency: "weekly" } as const;
|
|
61
|
+
|
|
62
|
+
/** How often a scheduled run (a full scan, then checking the links that are due) starts while Scheduled link checking is on. */
|
|
63
|
+
export const FREQUENCIES = ["daily", "weekly", "monthly"] as const;
|
|
64
|
+
export type Frequency = (typeof FREQUENCIES)[number];
|
|
65
|
+
const FREQUENCY_DAYS: Record<Frequency, number> = { daily: 1, weekly: 7, monthly: 30 };
|
|
66
|
+
const isFrequency = (v: unknown): v is Frequency => typeof v === "string" && (FREQUENCIES as readonly string[]).includes(v);
|
|
67
|
+
|
|
68
|
+
async function readLinksSettings(ctx: PluginContext) {
|
|
69
|
+
const [budget, internal, userAgent, frequency] = await Promise.all([
|
|
70
|
+
ctx.settings.get<number>("linksCheckBudget"),
|
|
71
|
+
ctx.settings.get<boolean>("linksCheckInternal"),
|
|
72
|
+
ctx.settings.get<string>("linksUserAgent"),
|
|
73
|
+
ctx.settings.get<string>("linksFrequency"),
|
|
74
|
+
]);
|
|
75
|
+
return {
|
|
76
|
+
checkBudget: typeof budget === "number" && Number.isFinite(budget) ? budget : LINKS_DEFAULTS.checkBudget,
|
|
77
|
+
checkInternal: typeof internal === "boolean" ? internal : LINKS_DEFAULTS.checkInternal,
|
|
78
|
+
userAgent: typeof userAgent === "string" ? userAgent : LINKS_DEFAULTS.userAgent,
|
|
79
|
+
frequency: isFrequency(frequency) ? frequency : LINKS_DEFAULTS.frequency,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const linksSettingsInput = z.object({
|
|
84
|
+
checkBudget: z.number().int().min(10, "Use at least 10 subrequests per run.").max(900, "Use at most 900 subrequests per run."),
|
|
85
|
+
checkInternal: z.boolean(),
|
|
86
|
+
userAgent: z.string().trim().max(500),
|
|
87
|
+
frequency: z.enum(FREQUENCIES).optional(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Schedule ─────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** The scheduled run in progress, or the last one (plugin KV). */
|
|
93
|
+
export const RUN_KEY = "links:scheduledRun";
|
|
94
|
+
export interface ScheduledRun {
|
|
95
|
+
startedAt: string;
|
|
96
|
+
phase: "scan" | "check" | "done";
|
|
97
|
+
finishedAt?: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Cron ticks drift by seconds to minutes, so a run is due a little before the full interval has passed. */
|
|
101
|
+
const DUE_SLACK_MS = 5 * 60_000;
|
|
102
|
+
|
|
103
|
+
/** Whether a new scheduled run should start: never ran, or the last one started at least `frequency` ago. */
|
|
104
|
+
export function isRunDue(frequency: Frequency, lastStartedAt: string | null | undefined, now = Date.now()): boolean {
|
|
105
|
+
if (!lastStartedAt) return true;
|
|
106
|
+
const started = Date.parse(lastStartedAt);
|
|
107
|
+
if (!Number.isFinite(started)) return true;
|
|
108
|
+
return now - started >= FREQUENCY_DAYS[frequency] * 86_400_000 - DUE_SLACK_MS;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
type Content = NonNullable<PluginContext["content"]>;
|
|
114
|
+
type WritableContent = Content & { update: NonNullable<Content["update"]> };
|
|
115
|
+
|
|
116
|
+
function writable(ctx: PluginContext): WritableContent {
|
|
117
|
+
const content = ctx.content;
|
|
118
|
+
if (!content?.update) throw PluginRouteError.badRequest("Link Manager needs the content:write capability.");
|
|
119
|
+
return content as WritableContent;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const requestOps = () => new Ops(REQUEST_OPS, Date.now() + REQUEST_MS);
|
|
123
|
+
|
|
124
|
+
// ── Checking ─────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export interface CheckRun {
|
|
127
|
+
checked: number;
|
|
128
|
+
/** Links that were due when the run started (up to 100). */
|
|
129
|
+
due: number;
|
|
130
|
+
byStatus: Partial<Record<LinkStatus, number>>;
|
|
131
|
+
exhausted: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check due links (or `ids`) within a subrequest budget (HTTP requests and
|
|
136
|
+
* database statements together) and a wall-clock deadline.
|
|
137
|
+
*/
|
|
138
|
+
/** Service binding to the site's own Worker, used to check internal links. */
|
|
139
|
+
export const SELF_BINDING = "SELF";
|
|
140
|
+
|
|
141
|
+
async function selfBinding(): Promise<{ fetch(url: string, init?: RequestInit): Promise<Response> } | null> {
|
|
142
|
+
try {
|
|
143
|
+
const env = await workerEnv();
|
|
144
|
+
const binding = env[SELF_BINDING] as { fetch?: unknown } | undefined;
|
|
145
|
+
return binding && typeof binding.fetch === "function" ? (binding as { fetch(url: string, init?: RequestInit): Promise<Response> }) : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function runChecks(ctx: PluginContext, options: { ids?: string[]; budget?: number; deadline?: number } = {}): Promise<CheckRun> {
|
|
152
|
+
const settings = await readLinksSettings(ctx);
|
|
153
|
+
const budget: Budget = { left: options.budget ?? settings.checkBudget };
|
|
154
|
+
const checkInternal = settings.checkInternal;
|
|
155
|
+
const userAgent = settings.userAgent.trim() || undefined;
|
|
156
|
+
budget.left -= 5; // The four settings reads and the candidate query.
|
|
157
|
+
const deadline = options.deadline ?? Number.POSITIVE_INFINITY;
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
// A Worker can't fetch its own custom domain over HTTP (Cloudflare answers 522), so
|
|
160
|
+
// internal links go through a service binding to the site's own Worker when there is one.
|
|
161
|
+
const self = await selfBinding();
|
|
162
|
+
const siteHost = (() => {
|
|
163
|
+
try {
|
|
164
|
+
return ctx.site.url ? new URL(ctx.site.url).host : "";
|
|
165
|
+
} catch {
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
const fetcher = (url: string, init: RequestInit) =>
|
|
170
|
+
self && siteHost && new URL(url).host === siteHost ? self.fetch(url, init) : fetch(url, init);
|
|
171
|
+
|
|
172
|
+
let candidates: Array<{ id: string; data: UrlRow }>;
|
|
173
|
+
if (options.ids) {
|
|
174
|
+
const ops = new Ops();
|
|
175
|
+
const found = await getMany(urls(ctx), options.ids, ops);
|
|
176
|
+
budget.left -= ops.used - 1;
|
|
177
|
+
candidates = [...found].map(([id, data]) => ({ id, data }));
|
|
178
|
+
} else {
|
|
179
|
+
const page = await urls(ctx).query({
|
|
180
|
+
where: { ignored: false, nextCheckAt: { lte: new Date(now).toISOString() } },
|
|
181
|
+
orderBy: { nextCheckAt: "asc" },
|
|
182
|
+
limit: 100,
|
|
183
|
+
});
|
|
184
|
+
candidates = page.items;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const run: CheckRun = { checked: 0, due: candidates.length, byStatus: {}, exhausted: false };
|
|
188
|
+
const queue = [...candidates];
|
|
189
|
+
const worker = async () => {
|
|
190
|
+
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
191
|
+
// Room for the write plus a HEAD and a GET.
|
|
192
|
+
if (budget.left < 3 || Date.now() >= deadline) {
|
|
193
|
+
run.exhausted = true;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const { id, data } = next;
|
|
197
|
+
budget.left--; // The row write below.
|
|
198
|
+
const checkedAt = new Date().toISOString();
|
|
199
|
+
if (!data.resolved || (data.internal && (!checkInternal || !self))) {
|
|
200
|
+
await urls(ctx).put(id, {
|
|
201
|
+
...data,
|
|
202
|
+
note: !data.resolved
|
|
203
|
+
? "Can't be resolved to a web address."
|
|
204
|
+
: !checkInternal
|
|
205
|
+
? "Internal links aren't checked (Link Manager → Settings)."
|
|
206
|
+
: `Internal links need a "${SELF_BINDING}" service binding to the site's own Worker (see docs/links.md in the Coywolf Pack repository).`,
|
|
207
|
+
nextCheckAt: nextCheckAt("ok", now),
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const outcome = await checkUrl(new URL(data.resolved), budget, { userAgent, fetcher });
|
|
213
|
+
await urls(ctx).put(id, {
|
|
214
|
+
...data,
|
|
215
|
+
status: outcome.status,
|
|
216
|
+
rank: STATUS_RANK[outcome.status],
|
|
217
|
+
code: outcome.code || null,
|
|
218
|
+
finalUrl: outcome.finalUrl,
|
|
219
|
+
chain: outcome.chain.slice(0, 8),
|
|
220
|
+
note: outcome.note.slice(0, 300),
|
|
221
|
+
checkedAt,
|
|
222
|
+
nextCheckAt: nextCheckAt(outcome.status, Date.now()),
|
|
223
|
+
});
|
|
224
|
+
run.checked++;
|
|
225
|
+
run.byStatus[outcome.status] = (run.byStatus[outcome.status] ?? 0) + 1;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof BudgetExhausted) {
|
|
228
|
+
run.exhausted = true;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
ctx.log.warn("links: check failed", { url: data.url, error: String(error) });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
236
|
+
if (run.checked) await invalidateCounts(ctx);
|
|
237
|
+
return run;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Editing ──────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export interface EditResult {
|
|
243
|
+
entries: number;
|
|
244
|
+
links: number;
|
|
245
|
+
published: number;
|
|
246
|
+
/** Fixed in the entry's draft only (it had unpublished changes, or is scheduled). */
|
|
247
|
+
staged: number;
|
|
248
|
+
scheduled: number;
|
|
249
|
+
skipped: number;
|
|
250
|
+
/** Entries changed by someone else while this ran; retry them. */
|
|
251
|
+
conflicts: number;
|
|
252
|
+
failed: Array<{ entry: string; error: string }>;
|
|
253
|
+
/** Entry keys after `next` still to process; call again with `after: next`. */
|
|
254
|
+
remaining: number;
|
|
255
|
+
next: string | null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const EMPTY_EDIT: EditResult = {
|
|
259
|
+
entries: 0,
|
|
260
|
+
links: 0,
|
|
261
|
+
published: 0,
|
|
262
|
+
staged: 0,
|
|
263
|
+
scheduled: 0,
|
|
264
|
+
skipped: 0,
|
|
265
|
+
conflicts: 0,
|
|
266
|
+
failed: [],
|
|
267
|
+
remaining: 0,
|
|
268
|
+
next: null,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** Statements one entry edit may take (reads, update, publish, re-index). */
|
|
272
|
+
const EDIT_RESERVE = 30;
|
|
273
|
+
|
|
274
|
+
const versionOf = (item: { version?: number; updatedAt?: string; draftRevisionId?: string | null }) =>
|
|
275
|
+
`${item.version ?? ""}|${item.updatedAt ?? ""}|${item.draftRevisionId ?? ""}`;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Apply link edits to every entry that uses the given URLs. An entry with
|
|
279
|
+
* unpublished changes is edited in its draft only; a published entry with no
|
|
280
|
+
* pending draft is republished so the fix goes live. An entry that changes
|
|
281
|
+
* while it's being edited is skipped and reported, never overwritten.
|
|
282
|
+
*/
|
|
283
|
+
async function applyEdits(ctx: PluginContext, plan: Map<string, LinkEdit>, ids: string[], after: string | null): Promise<EditResult> {
|
|
284
|
+
const ops = requestOps();
|
|
285
|
+
const content = writable(ctx);
|
|
286
|
+
const collections = await linkCollections(ctx);
|
|
287
|
+
const usage = await queryIn(refs(ctx), "urlId", ids, ops);
|
|
288
|
+
const keys = [...new Set(usage.map((r) => r.data.entryKey))].sort().filter((k) => !after || k > after);
|
|
289
|
+
const result: EditResult = { ...EMPTY_EDIT, failed: [] };
|
|
290
|
+
let done = 0;
|
|
291
|
+
|
|
292
|
+
for (const key of keys) {
|
|
293
|
+
if (!ops.has(EDIT_RESERVE)) break;
|
|
294
|
+
done++;
|
|
295
|
+
result.next = key;
|
|
296
|
+
const slash = key.indexOf("/");
|
|
297
|
+
const collection = key.slice(0, slash);
|
|
298
|
+
const id = key.slice(slash + 1);
|
|
299
|
+
try {
|
|
300
|
+
const info = collections.get(collection);
|
|
301
|
+
ops.spend(2);
|
|
302
|
+
const item = await content.get(collection, id);
|
|
303
|
+
if (!item || !info) {
|
|
304
|
+
await removeEntry(ctx, collection, id, ops);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const hadDraft = Boolean(item.draftRevisionId);
|
|
308
|
+
let data = await latestData(ctx, collection, item, ops);
|
|
309
|
+
const patch: Record<string, unknown> = {};
|
|
310
|
+
let changed = 0;
|
|
311
|
+
for (const [href, edit] of plan) {
|
|
312
|
+
const step = transformEntry(data, info.fields, (h) => h === href, edit);
|
|
313
|
+
changed += step.changed;
|
|
314
|
+
result.skipped += step.skipped;
|
|
315
|
+
Object.assign(patch, step.patch);
|
|
316
|
+
data = { ...data, ...step.patch };
|
|
317
|
+
}
|
|
318
|
+
if (changed && Object.keys(patch).length) {
|
|
319
|
+
// Re-read right before writing: if anyone saved in between, leave the entry alone.
|
|
320
|
+
ops.spend(2);
|
|
321
|
+
const fresh = await content.get(collection, id);
|
|
322
|
+
if (!fresh || versionOf(fresh) !== versionOf(item)) {
|
|
323
|
+
result.conflicts++;
|
|
324
|
+
result.failed.push({ entry: key, error: "Changed while being edited. Run the action again for this link." });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
ops.spend(5);
|
|
328
|
+
await content.update(collection, id, patch);
|
|
329
|
+
result.entries++;
|
|
330
|
+
result.links += changed;
|
|
331
|
+
if (item.status === "scheduled") result.scheduled++;
|
|
332
|
+
if (info.revisions && item.status === "published") {
|
|
333
|
+
let published = false;
|
|
334
|
+
if (!hadDraft && content.getVersioned && content.publish) {
|
|
335
|
+
ops.spend(2);
|
|
336
|
+
const versioned = await content.getVersioned(collection, id);
|
|
337
|
+
// Publish only the draft this edit created: exactly one version after the one read.
|
|
338
|
+
if (versioned && versioned.item.version === (fresh.version ?? 0) + 1) {
|
|
339
|
+
ops.spend(5);
|
|
340
|
+
await content.publish(collection, id, { _rev: versioned._rev });
|
|
341
|
+
published = true;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (published) result.published++;
|
|
345
|
+
else result.staged++;
|
|
346
|
+
} else if (info.revisions && item.status === "scheduled") {
|
|
347
|
+
result.staged++;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
await indexEntry(ctx, collection, { ...item, data }, { ops });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
result.failed.push({ entry: key, error: String(error).slice(0, 200) });
|
|
353
|
+
ctx.log.error("links: edit failed", { entry: key, error: String(error) });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
result.remaining = keys.length - done;
|
|
357
|
+
await invalidateCounts(ctx);
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Listing ──────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
const listInput = z.object({
|
|
364
|
+
status: z.enum(["all", "ignored", ...STATUSES] as [string, ...string[]]).optional(),
|
|
365
|
+
scope: z.enum(["all", "internal", "external"]).optional(),
|
|
366
|
+
host: z.string().max(253).optional(),
|
|
367
|
+
q: z.string().max(500).optional(),
|
|
368
|
+
cursor: z.string().max(2000).nullish(),
|
|
369
|
+
sort: z.enum(["status", "url", "checked"]).optional(),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
/** One page of links, filtered and sorted by indexed storage queries. */
|
|
373
|
+
async function list(ctx: PluginContext, input: z.infer<typeof listInput>) {
|
|
374
|
+
const status = input.status ?? "all";
|
|
375
|
+
const where: Record<string, unknown> = { ignored: status === "ignored" };
|
|
376
|
+
if (status !== "all" && status !== "ignored") where.status = status;
|
|
377
|
+
if (input.scope === "internal") where.internal = true;
|
|
378
|
+
if (input.scope === "external") where.internal = false;
|
|
379
|
+
const host = input.host?.trim().toLowerCase().replace(/^www\./, "");
|
|
380
|
+
if (host) where.host = host;
|
|
381
|
+
const q = input.q?.trim();
|
|
382
|
+
if (q) {
|
|
383
|
+
// Storage matches prefixes: a URL or path searches URLs, anything else searches domains.
|
|
384
|
+
if (/^(https?:|\/)/i.test(q)) where.url = { startsWith: q };
|
|
385
|
+
else if (!host) where.host = { startsWith: q.toLowerCase().replace(/^www\./, "") };
|
|
386
|
+
}
|
|
387
|
+
const sort = input.sort ?? "status";
|
|
388
|
+
const orderBy: Record<string, "asc" | "desc"> = sort === "url" ? { url: "asc" } : sort === "checked" ? { checkedAt: "desc" } : { rank: "asc" };
|
|
389
|
+
|
|
390
|
+
// biome-ignore lint/suspicious/noExplicitAny: WhereClause is EmDash's.
|
|
391
|
+
const page = await urls(ctx).query({ where: where as any, orderBy, limit: PAGE_SIZE, cursor: input.cursor ?? undefined });
|
|
392
|
+
// biome-ignore lint/suspicious/noExplicitAny: WhereClause is EmDash's.
|
|
393
|
+
const total = await urls(ctx).count(where as any);
|
|
394
|
+
const usage = await queryIn(
|
|
395
|
+
refs(ctx),
|
|
396
|
+
"urlId",
|
|
397
|
+
page.items.map((r) => r.id),
|
|
398
|
+
);
|
|
399
|
+
const byUrl = new Map<string, RefRow[]>();
|
|
400
|
+
for (const { data } of usage) byUrl.set(data.urlId, [...(byUrl.get(data.urlId) ?? []), data]);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
items: page.items.map(({ id, data }) => {
|
|
404
|
+
const used = (byUrl.get(id) ?? []).sort((a, b) => a.title.localeCompare(b.title));
|
|
405
|
+
return {
|
|
406
|
+
id,
|
|
407
|
+
...data,
|
|
408
|
+
refs: used.length,
|
|
409
|
+
usedIn: used.map((r) => ({
|
|
410
|
+
collection: r.collection,
|
|
411
|
+
entryId: r.entryId,
|
|
412
|
+
title: r.title,
|
|
413
|
+
status: r.entryStatus,
|
|
414
|
+
kinds: r.kinds,
|
|
415
|
+
anchors: r.anchors,
|
|
416
|
+
count: r.count,
|
|
417
|
+
})),
|
|
418
|
+
};
|
|
419
|
+
}),
|
|
420
|
+
total,
|
|
421
|
+
pageSize: PAGE_SIZE,
|
|
422
|
+
nextCursor: page.hasMore ? (page.cursor ?? null) : null,
|
|
423
|
+
counts: await getCounts(ctx, 60_000),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Module ───────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
const ids = z.array(z.string().regex(/^[0-9a-f]{24}$/)).min(1).max(500);
|
|
430
|
+
const targetUrl = z
|
|
431
|
+
.string()
|
|
432
|
+
.trim()
|
|
433
|
+
.min(1, "Enter the new URL.")
|
|
434
|
+
.max(2048)
|
|
435
|
+
.refine(isAllowedTarget, "Use an http(s) URL, a path starting with a single /, mailto: or tel:. Protocol-relative URLs (//host) aren't allowed.");
|
|
436
|
+
|
|
437
|
+
export function linksModule() {
|
|
438
|
+
const guard = (ctx: PluginContext, feature = "links") => requireFeature(ctx, feature);
|
|
439
|
+
|
|
440
|
+
const routes = {
|
|
441
|
+
"links/list": definePluginRoute({
|
|
442
|
+
permission: "plugins:manage",
|
|
443
|
+
methods: ["POST"],
|
|
444
|
+
request: { body: "json" },
|
|
445
|
+
handler: async (ctx) => {
|
|
446
|
+
await guard(ctx);
|
|
447
|
+
const input = parseInput(listInput, ctx.input ?? {});
|
|
448
|
+
const features = await ctxFeatures(ctx);
|
|
449
|
+
return {
|
|
450
|
+
...(await list(ctx, input)),
|
|
451
|
+
scan: (await getScan(ctx)) ?? IDLE_SCAN,
|
|
452
|
+
checking: features["links.check"] ?? false,
|
|
453
|
+
siteUrlKnown: Boolean(ctx.site?.url),
|
|
454
|
+
ignores: await getIgnores(ctx),
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
}),
|
|
458
|
+
|
|
459
|
+
/** Link checking settings (the page's Settings dialog). */
|
|
460
|
+
"links/settings": {
|
|
461
|
+
permission: "plugins:manage" as const,
|
|
462
|
+
handler: async (ctx: PluginContext) => {
|
|
463
|
+
await guard(ctx);
|
|
464
|
+
return readLinksSettings(ctx);
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
"links/settings/save": definePluginRoute({
|
|
469
|
+
permission: "plugins:manage",
|
|
470
|
+
methods: ["POST"],
|
|
471
|
+
request: { body: "json" },
|
|
472
|
+
handler: async (ctx) => {
|
|
473
|
+
await guard(ctx);
|
|
474
|
+
const input = parseInput(linksSettingsInput, ctx.input);
|
|
475
|
+
await ctx.settings.set("linksCheckBudget", input.checkBudget);
|
|
476
|
+
await ctx.settings.set("linksCheckInternal", input.checkInternal);
|
|
477
|
+
await ctx.settings.set("linksUserAgent", input.userAgent);
|
|
478
|
+
if (input.frequency) await ctx.settings.set("linksFrequency", input.frequency);
|
|
479
|
+
ctx.log.info("Link Manager settings saved", { checkBudget: input.checkBudget, checkInternal: input.checkInternal, frequency: input.frequency });
|
|
480
|
+
return readLinksSettings(ctx);
|
|
481
|
+
},
|
|
482
|
+
}),
|
|
483
|
+
|
|
484
|
+
"links/summary": {
|
|
485
|
+
permission: "plugins:manage" as const,
|
|
486
|
+
handler: async (ctx: PluginContext) => {
|
|
487
|
+
await guard(ctx);
|
|
488
|
+
return { counts: await getCounts(ctx), scan: (await getScan(ctx)) ?? IDLE_SCAN };
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
/** Start (with `restart`, or when never scanned) or continue a full scan, one bounded step per call. */
|
|
493
|
+
"links/scan": definePluginRoute({
|
|
494
|
+
permission: "plugins:manage",
|
|
495
|
+
methods: ["POST"],
|
|
496
|
+
request: { body: "json" },
|
|
497
|
+
handler: async (ctx) => {
|
|
498
|
+
await guard(ctx);
|
|
499
|
+
const { restart } = parseInput(z.object({ restart: z.boolean().optional() }), ctx.input ?? {});
|
|
500
|
+
if (restart || !(await getScan(ctx))) await startScan(ctx);
|
|
501
|
+
return scanStep(ctx, requestOps());
|
|
502
|
+
},
|
|
503
|
+
}),
|
|
504
|
+
|
|
505
|
+
"links/recheck": definePluginRoute({
|
|
506
|
+
permission: "plugins:manage",
|
|
507
|
+
methods: ["POST"],
|
|
508
|
+
request: { body: "json" },
|
|
509
|
+
handler: async (ctx) => {
|
|
510
|
+
await guard(ctx, "links.check");
|
|
511
|
+
const input = parseInput(z.object({ ids: ids.max(50).optional() }), ctx.input ?? {});
|
|
512
|
+
const run = await runChecks(ctx, { ids: input.ids, budget: 45, deadline: Date.now() + REQUEST_MS });
|
|
513
|
+
// Whatever didn't fit is queued for the next scheduled run.
|
|
514
|
+
if (input.ids && run.exhausted) {
|
|
515
|
+
const ops = requestOps();
|
|
516
|
+
const left = await getMany(urls(ctx), input.ids, ops);
|
|
517
|
+
const queued = [...left].filter(([, d]) => !d.checkedAt || Date.now() - Date.parse(d.checkedAt) > REQUEST_MS * 2);
|
|
518
|
+
await putMany(
|
|
519
|
+
urls(ctx),
|
|
520
|
+
queued.map(([id, data]) => ({ id, data: { ...data, nextCheckAt: "" } })),
|
|
521
|
+
ops,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
return run;
|
|
525
|
+
},
|
|
526
|
+
}),
|
|
527
|
+
|
|
528
|
+
"links/replace": definePluginRoute({
|
|
529
|
+
permission: "plugins:manage",
|
|
530
|
+
methods: ["POST"],
|
|
531
|
+
request: { body: "json" },
|
|
532
|
+
handler: async (ctx) => {
|
|
533
|
+
await guard(ctx);
|
|
534
|
+
const input = parseInput(z.object({ ids, to: targetUrl, after: z.string().max(600).nullish() }), ctx.input);
|
|
535
|
+
const found = await getMany(urls(ctx), input.ids);
|
|
536
|
+
const plan = new Map<string, LinkEdit>();
|
|
537
|
+
for (const row of found.values()) if (row.url !== input.to) plan.set(row.url, { type: "replace", to: input.to });
|
|
538
|
+
if (!plan.size) return { ...EMPTY_EDIT };
|
|
539
|
+
const result = await applyEdits(ctx, plan, input.ids, input.after ?? null);
|
|
540
|
+
ctx.log.info("links: replaced", { from: [...plan.keys()].slice(0, 10), to: input.to, entries: result.entries });
|
|
541
|
+
return result;
|
|
542
|
+
},
|
|
543
|
+
}),
|
|
544
|
+
|
|
545
|
+
"links/unlink": definePluginRoute({
|
|
546
|
+
permission: "plugins:manage",
|
|
547
|
+
methods: ["POST"],
|
|
548
|
+
request: { body: "json" },
|
|
549
|
+
handler: async (ctx) => {
|
|
550
|
+
await guard(ctx);
|
|
551
|
+
const input = parseInput(z.object({ ids, after: z.string().max(600).nullish() }), ctx.input);
|
|
552
|
+
const found = await getMany(urls(ctx), input.ids);
|
|
553
|
+
const plan = new Map<string, LinkEdit>([...found.values()].map((row) => [row.url, { type: "unlink" }]));
|
|
554
|
+
const result = await applyEdits(ctx, plan, input.ids, input.after ?? null);
|
|
555
|
+
ctx.log.info("links: unlinked", { urls: [...plan.keys()].slice(0, 10), entries: result.entries });
|
|
556
|
+
return result;
|
|
557
|
+
},
|
|
558
|
+
}),
|
|
559
|
+
|
|
560
|
+
"links/ignore": definePluginRoute({
|
|
561
|
+
permission: "plugins:manage",
|
|
562
|
+
methods: ["POST"],
|
|
563
|
+
request: { body: "json" },
|
|
564
|
+
handler: async (ctx) => {
|
|
565
|
+
await guard(ctx);
|
|
566
|
+
const input = parseInput(
|
|
567
|
+
z.object({
|
|
568
|
+
add: z.array(z.object({ type: z.enum(["domain", "url", "wildcard", "regex"]), value: z.string().max(1024) })).max(100).optional(),
|
|
569
|
+
remove: z.array(z.string().max(64)).max(500).optional(),
|
|
570
|
+
}),
|
|
571
|
+
ctx.input,
|
|
572
|
+
);
|
|
573
|
+
let rules = await getIgnores(ctx);
|
|
574
|
+
if (input.remove?.length) rules = rules.filter((r) => !input.remove?.includes(r.id));
|
|
575
|
+
for (const rule of input.add ?? []) {
|
|
576
|
+
let value: string;
|
|
577
|
+
try {
|
|
578
|
+
value = normalizeIgnore(rule.type, rule.value);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error instanceof IgnoreRuleError) throw PluginRouteError.badRequest(error.message);
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
if (rules.some((r) => r.type === rule.type && r.value === value)) continue;
|
|
584
|
+
rules.push({ id: crypto.randomUUID().slice(0, 8), type: rule.type, value } satisfies IgnoreRule);
|
|
585
|
+
}
|
|
586
|
+
if (rules.length > 1000) throw PluginRouteError.badRequest("That's too many ignore rules (1,000 at most).");
|
|
587
|
+
await ctx.kv.set(IGNORES_KEY, rules);
|
|
588
|
+
const changed = await reapplyIgnores(ctx, rules);
|
|
589
|
+
return { ignores: rules, changed };
|
|
590
|
+
},
|
|
591
|
+
}),
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const reindex = async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) => {
|
|
595
|
+
const c = event.content as { id?: string; slug?: string | null; status?: string; data?: Record<string, unknown> };
|
|
596
|
+
if (!c.id || !c.data) return;
|
|
597
|
+
await indexEntry(ctx, event.collection, { id: c.id, slug: c.slug, status: c.status, data: c.data });
|
|
598
|
+
await invalidateCounts(ctx);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const hooks = {
|
|
602
|
+
"content:afterSave": reindex,
|
|
603
|
+
"content:afterRestore": reindex,
|
|
604
|
+
"content:afterDelete": async (event: { id: string; collection: string }, ctx: PluginContext) => {
|
|
605
|
+
await removeEntry(ctx, event.collection, event.id);
|
|
606
|
+
await invalidateCounts(ctx);
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Scheduled runs. Both tasks run hourly, only while Scheduled link checking
|
|
612
|
+
* (`links.check`) is on (src/core/compose.ts gates them), so nothing is read
|
|
613
|
+
* while it's off. Each tick reads one KV row (and, between runs, the frequency); a run starts once the chosen
|
|
614
|
+
* frequency has passed, scans the content (continuing over the next ticks),
|
|
615
|
+
* then checks the links that are due until none are left.
|
|
616
|
+
*/
|
|
617
|
+
async function scanTask(ctx: PluginContext) {
|
|
618
|
+
const run = await ctx.kv.get<ScheduledRun>(RUN_KEY);
|
|
619
|
+
if (run?.phase === "check") return;
|
|
620
|
+
if (run?.phase !== "scan") {
|
|
621
|
+
const frequency = await ctx.settings.get<string>("linksFrequency");
|
|
622
|
+
if (!isRunDue(isFrequency(frequency) ? frequency : LINKS_DEFAULTS.frequency, run?.startedAt)) return;
|
|
623
|
+
if (!ctx.site?.url) {
|
|
624
|
+
ctx.log.warn("links: the site URL isn't set (Settings → General or astro.config `site`); scanning waits for it.");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
await ctx.kv.set(RUN_KEY, { startedAt: new Date().toISOString(), phase: "scan", finishedAt: null } satisfies ScheduledRun);
|
|
628
|
+
if ((await getScan(ctx))?.status !== "running") await startScan(ctx);
|
|
629
|
+
}
|
|
630
|
+
const ops = new Ops(SCAN_OPS, Date.now() + SCAN_MS);
|
|
631
|
+
await fixUnresolved(ctx, ops);
|
|
632
|
+
const state = await scanStep(ctx, ops);
|
|
633
|
+
if (state.status !== "running" && !state.busy) {
|
|
634
|
+
const current = await ctx.kv.get<ScheduledRun>(RUN_KEY);
|
|
635
|
+
if (current) await ctx.kv.set(RUN_KEY, { ...current, phase: "check" } satisfies ScheduledRun);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function checkTask(ctx: PluginContext) {
|
|
640
|
+
const current = await ctx.kv.get<ScheduledRun>(RUN_KEY);
|
|
641
|
+
if (current?.phase !== "check") return;
|
|
642
|
+
const run = await runChecks(ctx, { deadline: Date.now() + 10 * 60_000 });
|
|
643
|
+
if (run.checked) ctx.log.info("links: checked", run);
|
|
644
|
+
// Done when everything due fit in this tick; otherwise the next tick continues, but never
|
|
645
|
+
// past the next run's start (a site with many failing links, rechecked daily, would never drain).
|
|
646
|
+
const frequency = await ctx.settings.get<string>("linksFrequency");
|
|
647
|
+
const overdue = isRunDue(isFrequency(frequency) ? frequency : LINKS_DEFAULTS.frequency, current.startedAt);
|
|
648
|
+
if ((!run.exhausted && run.due < 100) || overdue) await ctx.kv.set(RUN_KEY, { ...current, phase: "done", finishedAt: new Date().toISOString() } satisfies ScheduledRun);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return { routes, hooks, scanTask, checkTask };
|
|
652
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { PluginContext } from "emdash";
|
|
2
|
+
|
|
3
|
+
import { registerFeatures } from "../core/features.js";
|
|
4
|
+
import type { PackModule } from "../core/module.js";
|
|
5
|
+
import { LINKS_CHECK_TASK, LINKS_SCAN_TASK, linksModule } from "./module.js";
|
|
6
|
+
import { STORAGE } from "./store.js";
|
|
7
|
+
|
|
8
|
+
const FEATURES = [
|
|
9
|
+
{
|
|
10
|
+
id: "links",
|
|
11
|
+
label: "Link Manager",
|
|
12
|
+
description: "An inventory of every link in your content, with bulk replace, unlink, and ignore rules.",
|
|
13
|
+
default: false,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "links.check",
|
|
17
|
+
label: "Scheduled link checking",
|
|
18
|
+
description:
|
|
19
|
+
"Scan content and check links on a schedule (weekly by default; daily or monthly on the Link Manager page) and flag broken, redirected and blocked ones. While off, nothing runs in the background.",
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
registerFeatures(FEATURES);
|
|
24
|
+
|
|
25
|
+
export function linksPack(): PackModule {
|
|
26
|
+
const links = linksModule();
|
|
27
|
+
return {
|
|
28
|
+
id: "links",
|
|
29
|
+
label: "Link Manager",
|
|
30
|
+
features: FEATURES,
|
|
31
|
+
routes: links.routes,
|
|
32
|
+
hooks: links.hooks,
|
|
33
|
+
tasks: [
|
|
34
|
+
// Hourly ticks continue a run in progress; a new run starts only when the chosen frequency is due.
|
|
35
|
+
{ name: LINKS_SCAN_TASK, schedule: "@hourly", feature: "links.check", handler: (ctx: PluginContext) => links.scanTask(ctx) },
|
|
36
|
+
{ name: LINKS_CHECK_TASK, schedule: "@hourly", feature: "links.check", handler: (ctx: PluginContext) => links.checkTask(ctx) },
|
|
37
|
+
],
|
|
38
|
+
adminPages: [{ path: "/links", label: "Link Manager", icon: "link" }],
|
|
39
|
+
widgets: [{ id: "links-status", title: "Links", size: "third" }],
|
|
40
|
+
storage: STORAGE,
|
|
41
|
+
capabilities: ["content:read", "content:write", "content:revisions:read", "content:publish", "schema:read"],
|
|
42
|
+
};
|
|
43
|
+
}
|