@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,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored image sizes: resized WebP and AVIF copies of media-library images,
|
|
3
|
+
* made once by the Worker (Images binding) and kept in the media bucket next
|
|
4
|
+
* to the originals, at
|
|
5
|
+
*
|
|
6
|
+
* v<VARIANTS_VERSION>/<id>-<width>.webp|.avif (keeps the ratio)
|
|
7
|
+
* v<VARIANTS_VERSION>/<id>-<width>x<height>.webp|.avif (cropped to fill, around the focal point)
|
|
8
|
+
*
|
|
9
|
+
* The widths are fixed here and made for every image (on upload, or by the
|
|
10
|
+
* opt-in backfill). The crops are the site's (`images.crops` in
|
|
11
|
+
* astro.config.mjs: the exact sizes its theme shows, such as list thumbnails
|
|
12
|
+
* and avatars) and are made on first use: when a page asks for a crop an
|
|
13
|
+
* image doesn't have yet (croppedImage in pack.ts), so only crops pages
|
|
14
|
+
* actually show cost a transformation.
|
|
15
|
+
*
|
|
16
|
+
* The media host serves them as plain files: no transformation per visit, no
|
|
17
|
+
* Accept-based variation, and a year-long immutable cache. Each image gets a
|
|
18
|
+
* record (src/images/variants-store.ts) once all of its copies are in the
|
|
19
|
+
* bucket; until then pages use the media host's /s/ resizing.
|
|
20
|
+
*
|
|
21
|
+
* The widths and quality are fixed here. Changing them means bumping
|
|
22
|
+
* VARIANTS_VERSION: renders then treat older records as missing, the backfill
|
|
23
|
+
* makes the new set, and its cleanup removes the previous version's files.
|
|
24
|
+
*
|
|
25
|
+
* Pure helpers plus generateVariants() (no imports), so tests can load them.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const VARIANTS_VERSION = 1;
|
|
29
|
+
/** Widths made for each image (only those smaller than the original; the original is the largest candidate). */
|
|
30
|
+
export const VARIANT_WIDTHS = [400, 640, 800, 1200, 1600] as const;
|
|
31
|
+
export const WEBP_QUALITY = 85;
|
|
32
|
+
export const AVIF_QUALITY = 80;
|
|
33
|
+
/** Cloudflare doesn't encode AVIF beyond this many pixels on either side; those sizes are stored as WebP under the .avif key too. */
|
|
34
|
+
export const AVIF_MAX_DIMENSION = 1200;
|
|
35
|
+
/** The Images binding's input limit. */
|
|
36
|
+
export const MAX_SOURCE_BYTES = 20 * 1024 * 1024;
|
|
37
|
+
/** Originals the Images binding reads on every plan (not AVIF, which needs Enterprise; GIF and SVG stay on /s/). */
|
|
38
|
+
export const VARIANT_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
|
|
39
|
+
export const VARIANT_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
40
|
+
|
|
41
|
+
export type Crop = readonly [number, number];
|
|
42
|
+
|
|
43
|
+
/** Pack-wide crops (none by default); set from the site's `images.crops`. */
|
|
44
|
+
let crops: string[] = [];
|
|
45
|
+
|
|
46
|
+
/** Valid "<w>x<h>" names of the given crops, sorted and deduplicated. */
|
|
47
|
+
export function normalizeCrops(list: ReadonlyArray<Crop> | null | undefined): string[] {
|
|
48
|
+
const names = new Set<string>();
|
|
49
|
+
for (const c of list ?? []) {
|
|
50
|
+
const [w, h] = [Math.round(Number(c?.[0])), Math.round(Number(c?.[1]))];
|
|
51
|
+
if (w >= 1 && h >= 1 && w <= 2560 && h <= 2560) names.add(`${w}x${h}`);
|
|
52
|
+
}
|
|
53
|
+
return [...names].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Set the site's crops (from the plugin options). */
|
|
57
|
+
export function setImageCrops(list: ReadonlyArray<Crop> | null | undefined): void {
|
|
58
|
+
crops = normalizeCrops(list);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The site's crops as "<w>x<h>" names. */
|
|
62
|
+
export function imageCrops(): string[] {
|
|
63
|
+
return crops;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cropSize = (name: string): [number, number] => name.split("x").map(Number) as [number, number];
|
|
67
|
+
|
|
68
|
+
/** What's stored for an image: the widths and crops in the bucket (WebP and AVIF each), or why there are none. */
|
|
69
|
+
export interface VariantDoc {
|
|
70
|
+
/** VARIANTS_VERSION the copies were made with. */
|
|
71
|
+
v: number;
|
|
72
|
+
/** Widths stored as both .webp and .avif. Empty when the original is already small. */
|
|
73
|
+
w: number[];
|
|
74
|
+
/** Crops ("<w>x<h>") stored as both .webp and .avif (added as pages first use them). */
|
|
75
|
+
c?: string[];
|
|
76
|
+
/** Set when the image can't have stored sizes (pages keep using /s/ for it, without retrying). */
|
|
77
|
+
skip?: string;
|
|
78
|
+
/** When the record was written (ISO). */
|
|
79
|
+
at: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const versionPrefix = (version = VARIANTS_VERSION) => `v${version}/`;
|
|
83
|
+
export const variantPrefix = (id: string, version = VARIANTS_VERSION) => `${versionPrefix(version)}${id}-`;
|
|
84
|
+
/** Key of a stored copy: `size` is a width, or a crop's "<w>x<h>". */
|
|
85
|
+
export const variantKey = (id: string, size: number | string, ext: "webp" | "avif", version = VARIANTS_VERSION) => `${variantPrefix(id, version)}${size}.${ext}`;
|
|
86
|
+
|
|
87
|
+
/** Why an image can't have stored sizes, or null when it can. */
|
|
88
|
+
export function ineligibleReason(mime: string | null | undefined, width: number | null | undefined, size: number | null | undefined): string | null {
|
|
89
|
+
if (!(VARIANT_MIME_TYPES as readonly string[]).includes(String(mime ?? "").toLowerCase())) return "type";
|
|
90
|
+
if (!width || width < 1) return "no-width";
|
|
91
|
+
if (size && size > MAX_SOURCE_BYTES) return "too-large";
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function eligible(mime: string | null | undefined, width: number | null | undefined, size: number | null | undefined): boolean {
|
|
96
|
+
return ineligibleReason(mime, width, size) === null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The widths to store for an original `width` pixels wide. */
|
|
100
|
+
export function plannedWidths(width: number, widths: readonly number[] = VARIANT_WIDTHS): number[] {
|
|
101
|
+
return widths.filter((w) => w < width);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether Cloudflare encodes AVIF at this output size (otherwise the WebP is stored under the .avif key too). */
|
|
105
|
+
export function avifEncoded(outW: number, outH: number): boolean {
|
|
106
|
+
return outW <= AVIF_MAX_DIMENSION && outH <= AVIF_MAX_DIMENSION;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Output height of a width copy (0 when the original's height is unknown). */
|
|
110
|
+
export function scaledHeight(w: number, width: number, height: number | null | undefined): number {
|
|
111
|
+
return height ? Math.round((height * w) / width) : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The crops of `list` that fit inside a `width` x `height` original (never upscaled). */
|
|
115
|
+
export function plannedCrops(width: number, height: number | null | undefined, list: string[] = crops): string[] {
|
|
116
|
+
if (!height) return [];
|
|
117
|
+
return list.filter((name) => {
|
|
118
|
+
const [w, h] = cropSize(name);
|
|
119
|
+
return w <= width && h <= height;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A record made with the current widths (and with copies, not a skip). */
|
|
124
|
+
export function currentDoc(doc: VariantDoc | null | undefined): doc is VariantDoc {
|
|
125
|
+
return Boolean(doc && doc.v === VARIANTS_VERSION && !doc.skip && Array.isArray(doc.w));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A crop of an image: stored ("stored"); can't be made, because the image is
|
|
130
|
+
* skipped, the crop is larger than the original or isn't one of the site's
|
|
131
|
+
* crops ("never": use /s/ for good); the image's widths are made but this
|
|
132
|
+
* crop isn't yet ("missing": make it now); or the image has no current
|
|
133
|
+
* record at all ("none").
|
|
134
|
+
*/
|
|
135
|
+
export function cropState(
|
|
136
|
+
doc: VariantDoc | null | undefined,
|
|
137
|
+
name: string,
|
|
138
|
+
width: number | null | undefined,
|
|
139
|
+
height: number | null | undefined,
|
|
140
|
+
list: string[] = crops,
|
|
141
|
+
): "stored" | "never" | "missing" | "none" {
|
|
142
|
+
if (!doc || doc.v !== VARIANTS_VERSION) return "none";
|
|
143
|
+
if (doc.skip) return "never";
|
|
144
|
+
if (doc.c?.includes(name)) return "stored";
|
|
145
|
+
if (!list.includes(name) || !width || !plannedCrops(width, height, [name]).length) return "never";
|
|
146
|
+
return "missing";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Parse a stored record; null when it isn't one. */
|
|
150
|
+
export function parseDoc(raw: unknown): VariantDoc | null {
|
|
151
|
+
let value = raw;
|
|
152
|
+
if (typeof raw === "string") {
|
|
153
|
+
try {
|
|
154
|
+
value = JSON.parse(raw);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const doc = value as Partial<VariantDoc> | null;
|
|
160
|
+
if (!doc || typeof doc !== "object" || typeof doc.v !== "number" || !Array.isArray(doc.w)) return null;
|
|
161
|
+
return {
|
|
162
|
+
v: doc.v,
|
|
163
|
+
w: doc.w.filter((n): n is number => typeof n === "number"),
|
|
164
|
+
...(Array.isArray(doc.c) ? { c: doc.c.filter((n): n is string => typeof n === "string") } : {}),
|
|
165
|
+
at: String(doc.at ?? ""),
|
|
166
|
+
...(doc.skip ? { skip: String(doc.skip) } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ResponsiveSources {
|
|
171
|
+
/** srcset of the AVIF copies (and the original as the largest candidate). */
|
|
172
|
+
avif: string;
|
|
173
|
+
/** srcset of the WebP copies and the original. */
|
|
174
|
+
webp: string;
|
|
175
|
+
/** A middle-sized copy for <img src> (the original when there are none). */
|
|
176
|
+
src: string;
|
|
177
|
+
/** The original. */
|
|
178
|
+
full: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* srcsets for an image with a current record. `originalUrl` is the original on
|
|
183
|
+
* the media host and is listed last at its own width, so large screens still
|
|
184
|
+
* get the full image. Browsers pick from the <source> matching a type they
|
|
185
|
+
* accept; its candidates may include the original (not AVIF), which they
|
|
186
|
+
* decode by its real type.
|
|
187
|
+
*/
|
|
188
|
+
export function variantSrcsets(cdn: string, id: string, doc: VariantDoc, originalUrl: string, originalWidth: number): ResponsiveSources {
|
|
189
|
+
const widths = doc.w.filter((w) => w < originalWidth).sort((a, b) => a - b);
|
|
190
|
+
const set = (ext: "webp" | "avif") => [...widths.map((w) => `${cdn}/${variantKey(id, w, ext, doc.v)} ${w}w`), `${originalUrl} ${originalWidth}w`].join(", ");
|
|
191
|
+
const middle = widths.includes(800) ? 800 : widths[widths.length - 1];
|
|
192
|
+
return { avif: set("avif"), webp: set("webp"), src: middle ? `${cdn}/${variantKey(id, middle, "webp", doc.v)}` : originalUrl, full: originalUrl };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** srcsets of stored crops: `[name, descriptor]` pairs such as ["600x315", "600w"] or ["50x50", "1x"]. */
|
|
196
|
+
export function cropSrcsets(cdn: string, id: string, doc: VariantDoc, entries: Array<[string, string]>, srcIndex = 0): { avif: string; webp: string; src: string } {
|
|
197
|
+
const set = (ext: "webp" | "avif") => entries.map(([name, d]) => `${cdn}/${variantKey(id, name, ext, doc.v)} ${d}`).join(", ");
|
|
198
|
+
const pick = entries[Math.min(Math.max(0, srcIndex), entries.length - 1)]?.[0] ?? "";
|
|
199
|
+
return { avif: set("avif"), webp: set("webp"), src: `${cdn}/${variantKey(id, pick, "webp", doc.v)}` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Making the copies ────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export interface VariantBucket {
|
|
205
|
+
get(key: string): Promise<{ arrayBuffer(): Promise<ArrayBuffer>; size?: number } | null>;
|
|
206
|
+
put(key: string, value: ArrayBuffer, options?: { httpMetadata?: { contentType?: string; cacheControl?: string } }): Promise<unknown>;
|
|
207
|
+
list(options: { prefix: string; cursor?: string; limit?: number }): Promise<{ objects: Array<{ key: string }>; truncated: boolean; cursor?: string }>;
|
|
208
|
+
delete(keys: string | string[]): Promise<unknown>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ImagesBinding {
|
|
212
|
+
input(stream: ReadableStream): {
|
|
213
|
+
transform(options: Record<string, unknown>): { output(options: { format: string; quality?: number }): Promise<{ response(): Response }> };
|
|
214
|
+
};
|
|
215
|
+
info?(stream: ReadableStream): Promise<{ width?: number; height?: number }>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const streamOf = (bytes: ArrayBuffer) => new Response(bytes).body as ReadableStream;
|
|
219
|
+
|
|
220
|
+
async function encode(images: ImagesBinding, bytes: ArrayBuffer, transform: Record<string, unknown>, format: "image/webp" | "image/avif", quality: number): Promise<{ body: ArrayBuffer; type: string }> {
|
|
221
|
+
const result = await images.input(streamOf(bytes)).transform(transform).output({ format, quality });
|
|
222
|
+
const response = result.response();
|
|
223
|
+
if (!response.ok) throw new Error(`Images binding answered ${response.status}`);
|
|
224
|
+
const type = response.headers.get("content-type") || format;
|
|
225
|
+
if (!type.startsWith("image/")) throw new Error(`Images binding returned ${type}`);
|
|
226
|
+
return { body: await response.arrayBuffer(), type };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Make and store the copies of one image: each planned width and crop as
|
|
231
|
+
* WebP and AVIF (AVIF only where Cloudflare encodes it; otherwise the WebP is
|
|
232
|
+
* stored under the .avif key too, so every size has both). Copies already in
|
|
233
|
+
* the bucket are kept. Returns the record to write once everything is stored;
|
|
234
|
+
* throws when a copy can't be made (nothing is recorded then, so it's retried).
|
|
235
|
+
*/
|
|
236
|
+
export async function generateVariants(options: {
|
|
237
|
+
bucket: VariantBucket;
|
|
238
|
+
images: ImagesBinding;
|
|
239
|
+
/** The original's bucket key. */
|
|
240
|
+
key: string;
|
|
241
|
+
/** Id used in the copies' keys (the media id, or a poster's key without its extension). */
|
|
242
|
+
id: string;
|
|
243
|
+
width: number;
|
|
244
|
+
height?: number | null;
|
|
245
|
+
/** Focal point (0–1 each) the crops keep in view; the center when unset. */
|
|
246
|
+
focal?: { x: number; y: number } | null;
|
|
247
|
+
widths?: readonly number[];
|
|
248
|
+
/** Crop names ("<w>x<h>") to make; none by default (crops are made on first use). */
|
|
249
|
+
crops?: string[];
|
|
250
|
+
now?: () => Date;
|
|
251
|
+
}): Promise<VariantDoc> {
|
|
252
|
+
const { bucket, images, key, id, width } = options;
|
|
253
|
+
const widths = plannedWidths(width, options.widths);
|
|
254
|
+
const cropNames = plannedCrops(width, options.height, options.crops ?? []);
|
|
255
|
+
const doc: VariantDoc = { v: VARIANTS_VERSION, w: widths, c: cropNames, at: (options.now?.() ?? new Date()).toISOString() };
|
|
256
|
+
type Job = { size: number | string; transform: Record<string, unknown>; outW: number; outH: number };
|
|
257
|
+
const jobs: Job[] = [
|
|
258
|
+
...widths.map((w) => ({ size: w, transform: { width: w }, outW: w, outH: scaledHeight(w, width, options.height) })),
|
|
259
|
+
...cropNames.map((name) => {
|
|
260
|
+
const [w, h] = cropSize(name);
|
|
261
|
+
const gravity = options.focal && Number.isFinite(options.focal.x) && Number.isFinite(options.focal.y) ? { gravity: { x: options.focal.x, y: options.focal.y } } : {};
|
|
262
|
+
return { size: name, transform: { width: w, height: h, fit: "cover", ...gravity }, outW: w, outH: h };
|
|
263
|
+
}),
|
|
264
|
+
];
|
|
265
|
+
if (!jobs.length) return doc;
|
|
266
|
+
const existing = new Set<string>();
|
|
267
|
+
let cursor: string | undefined;
|
|
268
|
+
do {
|
|
269
|
+
const page = await bucket.list({ prefix: variantPrefix(id), cursor });
|
|
270
|
+
for (const o of page.objects) existing.add(o.key);
|
|
271
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
272
|
+
} while (cursor);
|
|
273
|
+
const missing = jobs.filter((j) => !existing.has(variantKey(id, j.size, "webp")) || !existing.has(variantKey(id, j.size, "avif")));
|
|
274
|
+
if (!missing.length) return doc;
|
|
275
|
+
const object = await bucket.get(key);
|
|
276
|
+
if (!object) throw new Error(`${key} isn't in the media bucket`);
|
|
277
|
+
const bytes = await object.arrayBuffer();
|
|
278
|
+
if (bytes.byteLength > MAX_SOURCE_BYTES) throw new Error(`${key} is over 20 MB`);
|
|
279
|
+
const put = (k: string, file: { body: ArrayBuffer; type: string }) => bucket.put(k, file.body, { httpMetadata: { contentType: file.type, cacheControl: VARIANT_CACHE_CONTROL } });
|
|
280
|
+
// One size at a time (memory), both formats together.
|
|
281
|
+
for (const job of missing) {
|
|
282
|
+
const avifOk = avifEncoded(job.outW, job.outH);
|
|
283
|
+
const [webp, avif] = await Promise.all([
|
|
284
|
+
encode(images, bytes, job.transform, "image/webp", WEBP_QUALITY),
|
|
285
|
+
avifOk ? encode(images, bytes, job.transform, "image/avif", AVIF_QUALITY).catch(() => null) : Promise.resolve(null),
|
|
286
|
+
]);
|
|
287
|
+
await Promise.all([put(variantKey(id, job.size, "webp"), webp), put(variantKey(id, job.size, "avif"), avif ?? webp)]);
|
|
288
|
+
}
|
|
289
|
+
return doc;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Delete every page of keys under `prefix` (1000 per call), stopping after `budgetMs`. True when none are left. */
|
|
293
|
+
async function deletePrefix(bucket: VariantBucket, prefix: string, deadline: number): Promise<boolean> {
|
|
294
|
+
for (;;) {
|
|
295
|
+
const page = await bucket.list({ prefix, limit: 1000 });
|
|
296
|
+
if (page.objects.length) await bucket.delete(page.objects.map((o) => o.key));
|
|
297
|
+
if (!page.truncated && page.objects.length < 1000) return true;
|
|
298
|
+
if (Date.now() > deadline) return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Delete an image's copies of the given versions (when it's deleted from the media library). */
|
|
303
|
+
export async function deleteVariants(bucket: VariantBucket, versions: number[], id: string): Promise<void> {
|
|
304
|
+
for (const version of versions) if (version >= 1) await deletePrefix(bucket, variantPrefix(id, version), Number.POSITIVE_INFINITY);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Delete all copies of an older version. True when it's done; false when the time ran out (call again). */
|
|
308
|
+
export function deleteVersion(bucket: VariantBucket, version: number, budgetMs: number): Promise<boolean> {
|
|
309
|
+
if (version < 1) return Promise.resolve(true);
|
|
310
|
+
return deletePrefix(bucket, versionPrefix(version), Date.now() + budgetMs);
|
|
311
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coywolf Pack for EmDash: one native plugin with Coywolf's features for
|
|
3
|
+
* EmDash sites on Cloudflare. Register it in astro.config.mjs:
|
|
4
|
+
*
|
|
5
|
+
* ```js
|
|
6
|
+
* import { coywolfPlugin } from "@coywolf/emdash";
|
|
7
|
+
* emdash({ plugins: [coywolfPlugin({ backups: { name: "mysite" } })] });
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* and add `coywolfPack()` from "@coywolf/emdash/middleware" to the site's
|
|
11
|
+
* middleware. Every feature can be turned on or off under Plugins → Coywolf Pack.
|
|
12
|
+
*/
|
|
13
|
+
import type { PluginCapability, PluginDescriptor } from "emdash";
|
|
14
|
+
import { definePlugin } from "emdash";
|
|
15
|
+
|
|
16
|
+
import { composeHooks, hookCapabilities } from "./core/compose.js";
|
|
17
|
+
import { configureContentUrls } from "./core/content-url.js";
|
|
18
|
+
import { PLUGIN_ID, isOn, knownFeatures } from "./core/features.js";
|
|
19
|
+
import { featuresRoutes } from "./core/features-module.js";
|
|
20
|
+
import type { AdminPageDef, PackModule } from "./core/module.js";
|
|
21
|
+
import { secretSettingsSchema } from "./core/secrets.js";
|
|
22
|
+
import { MODULES } from "./modules.js";
|
|
23
|
+
import type { CoywolfOptions } from "./options.js";
|
|
24
|
+
import { VERSION } from "./version.js";
|
|
25
|
+
|
|
26
|
+
export type { BackupsOptions } from "./backups/module.js";
|
|
27
|
+
export type { RedirectsOptions } from "./redirects/module.js";
|
|
28
|
+
export type { VideosOptions } from "./videos/module.js";
|
|
29
|
+
export type { RobotsOptions } from "./robots/module.js";
|
|
30
|
+
export type { CoywolfOptions } from "./options.js";
|
|
31
|
+
export type { PrivateFormUploadsOptions } from "./formUploads/pack.js";
|
|
32
|
+
export { privateFormUploads } from "./formUploads/pack.js";
|
|
33
|
+
export type { ContentUrlOptions, TrailingSlash } from "./core/content-url.js";
|
|
34
|
+
|
|
35
|
+
const PACKAGE = "@coywolf/emdash";
|
|
36
|
+
|
|
37
|
+
/** The admin sidebar folder holding every pack page (EmDash 1.2.0+ keeps the pages' order inside it). */
|
|
38
|
+
const SIDEBAR_GROUP = "Coywolf Pack";
|
|
39
|
+
/** First in the folder (its icon is the folder's). Labeled "Features", not "Coywolf Pack": the folder already says that. */
|
|
40
|
+
const FEATURES_PAGE = { path: "/features", label: "Features", icon: "toggle-right" };
|
|
41
|
+
/** Always on (not a feature), listed right below Coywolf Pack. */
|
|
42
|
+
const PERFORMANCE_PAGE = { path: "/performance", label: "Performance", icon: "lightning" };
|
|
43
|
+
/** Module pages listed right after Performance (when their feature is on), ahead of the rest. */
|
|
44
|
+
const AFTER_PERFORMANCE = ["/images"];
|
|
45
|
+
|
|
46
|
+
function sidebar(modules: PackModule[]): AdminPageDef[] {
|
|
47
|
+
const pages = modules.flatMap((m) => m.adminPages ?? []);
|
|
48
|
+
const early = AFTER_PERFORMANCE.flatMap((path) => pages.filter((p) => p.path === path));
|
|
49
|
+
return [FEATURES_PAGE, PERFORMANCE_PAGE, ...early, ...pages.filter((p) => !early.includes(p))].map((p) => ({ ...p, group: SIDEBAR_GROUP }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildModules(options: CoywolfOptions): PackModule[] {
|
|
53
|
+
// Module-level, like the search rate limit: createPlugin() runs when the Worker isolate
|
|
54
|
+
// starts (virtual:emdash/plugins), so middleware and Astro components see it too.
|
|
55
|
+
configureContentUrls({ urls: options.urls, trailingSlash: options.trailingSlash, termParents: options.termParents, pageParents: options.pageParents });
|
|
56
|
+
return MODULES.map((factory) => factory(options)).filter((m): m is PackModule => m !== null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function surfaces(modules: PackModule[]) {
|
|
60
|
+
return {
|
|
61
|
+
pages: sidebar(modules),
|
|
62
|
+
widgets: modules.flatMap((m) => m.widgets ?? []),
|
|
63
|
+
blocks: modules.flatMap((m) => m.portableTextBlocks ?? []),
|
|
64
|
+
settingsSchema: secretSettingsSchema(modules.map((m) => m.settingsSchema)),
|
|
65
|
+
storage: Object.assign({}, ...modules.map((m) => m.storage ?? {})),
|
|
66
|
+
capabilities: [...new Set([...modules.flatMap((m) => m.capabilities ?? []), ...hookCapabilities(modules)])] as PluginCapability[],
|
|
67
|
+
allowedHosts: [...new Set(modules.flatMap((m) => m.allowedHosts ?? []))],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `displayName` and `description` arrived in EmDash's descriptor after 1.4; declared here until its types have them. */
|
|
72
|
+
type Descriptor = PluginDescriptor<CoywolfOptions> & { displayName?: string; description?: string };
|
|
73
|
+
|
|
74
|
+
export function coywolfPlugin(options: CoywolfOptions = {}): PluginDescriptor<CoywolfOptions> {
|
|
75
|
+
const s = surfaces(buildModules(options));
|
|
76
|
+
const descriptor: Descriptor = {
|
|
77
|
+
id: PLUGIN_ID,
|
|
78
|
+
// Shown in EmDash's Plugins list (EmDash 1.5+; earlier versions ignore them).
|
|
79
|
+
displayName: "Coywolf Pack",
|
|
80
|
+
description: "Enhances EmDash's features and adds ones it doesn't have: performance, backups, redirects, schema, discovery, robots.txt and more.",
|
|
81
|
+
version: VERSION,
|
|
82
|
+
format: "native",
|
|
83
|
+
entrypoint: PACKAGE,
|
|
84
|
+
adminEntry: `${PACKAGE}/admin`,
|
|
85
|
+
componentsEntry: `${PACKAGE}/astro`,
|
|
86
|
+
options,
|
|
87
|
+
adminPages: s.pages,
|
|
88
|
+
adminWidgets: s.widgets,
|
|
89
|
+
portableTextBlocks: s.blocks,
|
|
90
|
+
...(s.capabilities.length ? { capabilities: s.capabilities } : {}),
|
|
91
|
+
};
|
|
92
|
+
return descriptor;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The admin definition, with `pages` and `widgets` computed on read: EmDash
|
|
97
|
+
* builds the admin manifest (sidebar, dashboard) from them on every admin
|
|
98
|
+
* request, so a module whose main feature is off drops out of the sidebar.
|
|
99
|
+
* The Features and Performance pages always stay. Before the switches have been read in this
|
|
100
|
+
* isolate, everything is listed.
|
|
101
|
+
*/
|
|
102
|
+
function liveAdmin<T extends object>(modules: PackModule[], base: T) {
|
|
103
|
+
const on = (module: PackModule) => {
|
|
104
|
+
// A module without switches (Performance) is always on.
|
|
105
|
+
if (!module.features.length) return true;
|
|
106
|
+
const features = knownFeatures();
|
|
107
|
+
return !features || isOn(features, module.features[0]?.id ?? module.id);
|
|
108
|
+
};
|
|
109
|
+
return Object.defineProperties(base, {
|
|
110
|
+
pages: {
|
|
111
|
+
enumerable: true,
|
|
112
|
+
get: () => sidebar(modules.filter(on)),
|
|
113
|
+
},
|
|
114
|
+
widgets: {
|
|
115
|
+
enumerable: true,
|
|
116
|
+
get: () => modules.filter(on).flatMap((m) => m.widgets ?? []),
|
|
117
|
+
},
|
|
118
|
+
}) as T & { pages: PackModule["adminPages"]; widgets: PackModule["widgets"] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createPlugin(options: CoywolfOptions = {}) {
|
|
122
|
+
const modules = buildModules(options);
|
|
123
|
+
const s = surfaces(modules);
|
|
124
|
+
const { hooks, tasks } = composeHooks(modules, { tasks: [] });
|
|
125
|
+
|
|
126
|
+
return definePlugin({
|
|
127
|
+
id: PLUGIN_ID,
|
|
128
|
+
version: VERSION,
|
|
129
|
+
...(s.capabilities.length ? { capabilities: s.capabilities } : {}),
|
|
130
|
+
...(s.allowedHosts.length ? { allowedHosts: s.allowedHosts } : {}),
|
|
131
|
+
storage: s.storage,
|
|
132
|
+
admin: liveAdmin(modules, {
|
|
133
|
+
entry: `${PACKAGE}/admin`,
|
|
134
|
+
settingsSchema: s.settingsSchema,
|
|
135
|
+
...(s.blocks.length ? { portableTextBlocks: s.blocks } : {}),
|
|
136
|
+
}),
|
|
137
|
+
// biome-ignore lint/suspicious/noExplicitAny: composed handlers match EmDash's hook types at runtime.
|
|
138
|
+
hooks: hooks as any,
|
|
139
|
+
routes: Object.assign({}, featuresRoutes(modules, tasks), ...modules.map((m) => m.routes ?? {})),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export default createPlugin;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check one URL over HTTP: HEAD first, GET when HEAD fails or is refused,
|
|
3
|
+
* redirects followed by hand (up to MAX_HOPS) so the chain can be shown.
|
|
4
|
+
* Every request counts against a per-run budget, since Workers cap
|
|
5
|
+
* subrequests per invocation.
|
|
6
|
+
*/
|
|
7
|
+
import { type CheckOutcome, type Hop, classify, isBotWall, isPublicTarget } from "./classify.js";
|
|
8
|
+
|
|
9
|
+
const TIMEOUT_MS = 10_000;
|
|
10
|
+
const MAX_HOPS = 5;
|
|
11
|
+
|
|
12
|
+
// A current desktop Chrome with matching headers: many sites answer bots with 403/429/999.
|
|
13
|
+
export const BROWSER_UA =
|
|
14
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
|
|
15
|
+
const BROWSER_HEADERS: Record<string, string> = {
|
|
16
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
17
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
18
|
+
"Sec-CH-UA": '"Chromium";v="140", "Google Chrome";v="140", "Not?A_Brand";v="99"',
|
|
19
|
+
"Sec-CH-UA-Mobile": "?0",
|
|
20
|
+
"Sec-CH-UA-Platform": '"Windows"',
|
|
21
|
+
"Sec-Fetch-Dest": "document",
|
|
22
|
+
"Sec-Fetch-Mode": "navigate",
|
|
23
|
+
"Sec-Fetch-Site": "none",
|
|
24
|
+
"Sec-Fetch-User": "?1",
|
|
25
|
+
"Upgrade-Insecure-Requests": "1",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Requests left in this run. */
|
|
29
|
+
export interface Budget {
|
|
30
|
+
left: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class BudgetExhausted extends Error {}
|
|
34
|
+
|
|
35
|
+
type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
async function follow(
|
|
38
|
+
start: URL,
|
|
39
|
+
method: "HEAD" | "GET",
|
|
40
|
+
budget: Budget,
|
|
41
|
+
userAgent: string,
|
|
42
|
+
fetcher: Fetcher,
|
|
43
|
+
): Promise<{ chain: Hop[]; blocked: boolean; error?: string }> {
|
|
44
|
+
const chain: Hop[] = [];
|
|
45
|
+
let url = start;
|
|
46
|
+
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
|
47
|
+
if (!isPublicTarget(url)) return { chain, blocked: false, error: "Points to a private or local address" };
|
|
48
|
+
if (budget.left <= 0) throw new BudgetExhausted();
|
|
49
|
+
budget.left--;
|
|
50
|
+
let response: Response;
|
|
51
|
+
try {
|
|
52
|
+
response = await fetcher(url.href, {
|
|
53
|
+
method,
|
|
54
|
+
redirect: "manual",
|
|
55
|
+
headers: { ...BROWSER_HEADERS, "User-Agent": userAgent },
|
|
56
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const name = (error as Error)?.name;
|
|
60
|
+
const message = name === "TimeoutError" || name === "AbortError" ? "Timed out after 10 seconds" : describe(error);
|
|
61
|
+
return { chain, blocked: false, error: message };
|
|
62
|
+
}
|
|
63
|
+
// Don't download bodies.
|
|
64
|
+
await response.body?.cancel().catch(() => {});
|
|
65
|
+
chain.push({ url: url.href, code: response.status });
|
|
66
|
+
const location = response.headers.get("location");
|
|
67
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
68
|
+
try {
|
|
69
|
+
url = new URL(location, url);
|
|
70
|
+
} catch {
|
|
71
|
+
return { chain, blocked: false, error: `Invalid redirect location: ${location.slice(0, 200)}` };
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
return { chain, blocked: isBotWall(response.status, response.headers) };
|
|
76
|
+
}
|
|
77
|
+
return { chain, blocked: false, error: `More than ${MAX_HOPS} redirects` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function describe(error: unknown): string {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
if (/dns|getaddrinfo|ENOTFOUND|resolve/i.test(message)) return "Domain doesn't resolve (DNS)";
|
|
83
|
+
if (/certificate|ssl|tls/i.test(message)) return "TLS/certificate error";
|
|
84
|
+
if (/refused|ECONNREFUSED/i.test(message)) return "Connection refused";
|
|
85
|
+
return message.slice(0, 200) || "Request failed";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Check a URL. Throws BudgetExhausted when the run is out of requests before an answer. */
|
|
89
|
+
export async function checkUrl(url: URL, budget: Budget, options: { userAgent?: string; fetcher?: Fetcher } = {}): Promise<CheckOutcome> {
|
|
90
|
+
const userAgent = options.userAgent?.replace(/[\r\n\t\0]+/g, " ").trim() || BROWSER_UA;
|
|
91
|
+
const fetcher: Fetcher = options.fetcher ?? ((u, init) => fetch(u, init));
|
|
92
|
+
const head = await follow(url, "HEAD", budget, userAgent, fetcher);
|
|
93
|
+
const headLast = head.chain[head.chain.length - 1];
|
|
94
|
+
// Many servers mishandle HEAD (405, 404, 403, errors): confirm problems with GET.
|
|
95
|
+
if (head.error?.startsWith("Points to") || (!head.error && headLast && headLast.code < 400 && !head.blocked)) {
|
|
96
|
+
return classify(head.chain, head.blocked, head.error);
|
|
97
|
+
}
|
|
98
|
+
if (budget.left <= 0) throw new BudgetExhausted();
|
|
99
|
+
const get = await follow(url, "GET", budget, userAgent, fetcher);
|
|
100
|
+
return classify(get.chain, get.blocked, get.error);
|
|
101
|
+
}
|