@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,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media host setup and checks through the Cloudflare API, plus the live check
|
|
3
|
+
* of a media host. The API token is only ever sent to api.cloudflare.com: it
|
|
4
|
+
* is never logged, returned, or included in error messages.
|
|
5
|
+
*/
|
|
6
|
+
import { type CustomDomain, type RulesetRule, type SetupPlan, type SetupState, hostnameOf, planMediaHostSetup, zoneCandidates } from "./setup.js";
|
|
7
|
+
|
|
8
|
+
export const CLOUDFLARE_API_HOST = "api.cloudflare.com";
|
|
9
|
+
const API = `https://${CLOUDFLARE_API_HOST}/client/v4`;
|
|
10
|
+
|
|
11
|
+
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
12
|
+
|
|
13
|
+
export interface CloudflareConfig {
|
|
14
|
+
token: string;
|
|
15
|
+
accountId: string;
|
|
16
|
+
fetch?: Fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class CloudflareApiError extends Error {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
constructor(message: string, status: number) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What each step of the setup needs, for error messages when the token lacks it. */
|
|
28
|
+
const PERMISSION_HINT: Record<string, string> = {
|
|
29
|
+
zone: "Zone → Zone → Read",
|
|
30
|
+
transformations: "Zone → Zone Settings → Edit",
|
|
31
|
+
domain: "Account → Workers R2 Storage → Edit and Zone → DNS → Edit",
|
|
32
|
+
rules: "Zone → Transform Rules → Edit (and Account → Account Rulesets → Read)",
|
|
33
|
+
purge: "Zone → Cache Purge → Purge",
|
|
34
|
+
cacheRules: "Zone → Cache Rules → Edit",
|
|
35
|
+
tieredCache: "Zone → Zone Settings → Edit",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function cf<T>(config: CloudflareConfig, path: string, init: RequestInit = {}, step?: string): Promise<{ status: number; result: T | null }> {
|
|
39
|
+
const doFetch = config.fetch ?? fetch;
|
|
40
|
+
const res = await doFetch(`${API}${path}`, {
|
|
41
|
+
...init,
|
|
42
|
+
headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", ...(init.headers ?? {}) },
|
|
43
|
+
});
|
|
44
|
+
const body = (await res.json().catch(() => ({}))) as { success?: boolean; result?: T; errors?: Array<{ code?: number; message?: string }> };
|
|
45
|
+
if (!res.ok || body.success === false) {
|
|
46
|
+
const detail = body.errors?.map((e) => e.message).filter(Boolean).join("; ") || res.statusText;
|
|
47
|
+
const hint = (res.status === 401 || res.status === 403) && step ? ` The API token needs ${PERMISSION_HINT[step]}.` : "";
|
|
48
|
+
throw new CloudflareApiError(`Cloudflare API ${res.status}: ${detail}.${hint}`, res.status);
|
|
49
|
+
}
|
|
50
|
+
return { status: res.status, result: body.result ?? null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear everything Cloudflare's zone cache holds for one host name (the media
|
|
55
|
+
* host: originals and resized copies). Purge by hostname works on every plan.
|
|
56
|
+
*/
|
|
57
|
+
export async function purgeHost(config: CloudflareConfig, hostname: string): Promise<void> {
|
|
58
|
+
const zone = await findZone(config, hostname);
|
|
59
|
+
await cf(config, `/zones/${zone.id}/purge_cache`, { method: "POST", body: JSON.stringify({ hosts: [hostname] }) }, "purge");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Description that marks the media host's cache rule as the pack's own. */
|
|
63
|
+
export const MEDIA_CACHE_RULE = "Coywolf Pack: media host";
|
|
64
|
+
const YEAR = 31536000;
|
|
65
|
+
|
|
66
|
+
interface CacheRule {
|
|
67
|
+
id?: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
expression?: string;
|
|
70
|
+
action?: string;
|
|
71
|
+
action_parameters?: { cache?: boolean; edge_ttl?: { default?: number }; browser_ttl?: { default?: number } };
|
|
72
|
+
enabled?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mediaCacheRule(hostname: string): CacheRule {
|
|
76
|
+
return {
|
|
77
|
+
description: `${MEDIA_CACHE_RULE} (file names never change)`,
|
|
78
|
+
expression: `(http.host eq "${hostname}")`,
|
|
79
|
+
action: "set_cache_settings",
|
|
80
|
+
action_parameters: { cache: true, edge_ttl: { mode: "override_origin", default: YEAR }, browser_ttl: { mode: "override_origin", default: YEAR } } as CacheRule["action_parameters"],
|
|
81
|
+
enabled: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The media host's Cache Rule, if any: a zone cache rule for exactly this host
|
|
87
|
+
* name that caches it (made by the pack or by hand).
|
|
88
|
+
*/
|
|
89
|
+
export async function readMediaCacheRule(config: CloudflareConfig, hostname: string): Promise<{ zone: string; rule: { edgeTtl?: number; browserTtl?: number; enabled: boolean } | null }> {
|
|
90
|
+
const zone = await findZone(config, hostname);
|
|
91
|
+
let rules: CacheRule[] = [];
|
|
92
|
+
try {
|
|
93
|
+
const { result } = await cf<{ rules?: CacheRule[] }>(config, `/zones/${zone.id}/rulesets/phases/http_request_cache_settings/entrypoint`, {}, "cacheRules");
|
|
94
|
+
rules = result?.rules ?? [];
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!(error instanceof CloudflareApiError && error.status === 404)) throw error;
|
|
97
|
+
}
|
|
98
|
+
const rule = rules.find((r) => r.action === "set_cache_settings" && r.action_parameters?.cache === true && r.expression?.includes(`"${hostname}"`));
|
|
99
|
+
return {
|
|
100
|
+
zone: zone.name,
|
|
101
|
+
rule: rule ? { edgeTtl: rule.action_parameters?.edge_ttl?.default, browserTtl: rule.action_parameters?.browser_ttl?.default, enabled: rule.enabled !== false } : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Cache the media host at the edge and in browsers for a year (its file names
|
|
107
|
+
* are unique, so a changed file always has a new name). Adds the rule to the
|
|
108
|
+
* zone's cache rules, or updates the pack's earlier one; other rules are kept.
|
|
109
|
+
*/
|
|
110
|
+
export async function applyMediaCacheRule(config: CloudflareConfig, hostname: string): Promise<string> {
|
|
111
|
+
const zone = await findZone(config, hostname);
|
|
112
|
+
const phase = `/zones/${zone.id}/rulesets/phases/http_request_cache_settings/entrypoint`;
|
|
113
|
+
let ruleset: { id: string; rules?: CacheRule[] } | null = null;
|
|
114
|
+
try {
|
|
115
|
+
ruleset = (await cf<{ id: string; rules?: CacheRule[] }>(config, phase, {}, "cacheRules")).result;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!(error instanceof CloudflareApiError && error.status === 404)) throw error;
|
|
118
|
+
}
|
|
119
|
+
const rule = mediaCacheRule(hostname);
|
|
120
|
+
if (!ruleset) {
|
|
121
|
+
await cf(config, phase, { method: "PUT", body: JSON.stringify({ rules: [rule] }) }, "cacheRules");
|
|
122
|
+
return zone.name;
|
|
123
|
+
}
|
|
124
|
+
const existing = ruleset.rules?.find((r) => r.description?.startsWith(MEDIA_CACHE_RULE) && r.expression?.includes(`"${hostname}"`));
|
|
125
|
+
if (existing?.id) {
|
|
126
|
+
await cf(config, `/zones/${zone.id}/rulesets/${ruleset.id}/rules/${existing.id}`, { method: "PATCH", body: JSON.stringify(rule) }, "cacheRules");
|
|
127
|
+
} else {
|
|
128
|
+
await cf(config, `/zones/${zone.id}/rulesets/${ruleset.id}/rules`, { method: "POST", body: JSON.stringify(rule) }, "cacheRules");
|
|
129
|
+
}
|
|
130
|
+
return zone.name;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The zone that holds a host name, looked up in the account (most specific name first). */
|
|
134
|
+
export async function findZone(config: CloudflareConfig, hostname: string): Promise<{ id: string; name: string; plan?: string }> {
|
|
135
|
+
for (const name of zoneCandidates(hostname)) {
|
|
136
|
+
const { result } = await cf<Array<{ id: string; name: string; plan?: { name?: string } }>>(
|
|
137
|
+
config,
|
|
138
|
+
`/zones?name=${encodeURIComponent(name)}&account.id=${encodeURIComponent(config.accountId)}`,
|
|
139
|
+
{},
|
|
140
|
+
"zone",
|
|
141
|
+
);
|
|
142
|
+
const zone = result?.find((z) => z.name === name);
|
|
143
|
+
if (zone) return { id: zone.id, name: zone.name, plan: zone.plan?.name };
|
|
144
|
+
}
|
|
145
|
+
throw new CloudflareApiError(
|
|
146
|
+
`No zone for ${hostname} in this Cloudflare account. Add the site's domain to the account, or check the account ID. The API token needs ${PERMISSION_HINT.zone}.`,
|
|
147
|
+
404,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function readRules(config: CloudflareConfig, zoneId: string): Promise<RulesetRule[] | null> {
|
|
152
|
+
try {
|
|
153
|
+
const { result } = await cf<{ rules?: RulesetRule[] }>(config, `/zones/${zoneId}/rulesets/phases/http_request_transform/entrypoint`, {}, "rules");
|
|
154
|
+
return result?.rules ?? [];
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error instanceof CloudflareApiError && error.status === 404) return null;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface SetupTarget {
|
|
162
|
+
host: string;
|
|
163
|
+
bucket: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface SetupContext {
|
|
167
|
+
zone: { id: string; name: string; plan?: string };
|
|
168
|
+
state: SetupState;
|
|
169
|
+
plan: SetupPlan;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Read the current state and plan the setup (changes nothing). */
|
|
173
|
+
export async function planSetup(config: CloudflareConfig, target: SetupTarget): Promise<SetupContext> {
|
|
174
|
+
const hostname = hostnameOf(target.host);
|
|
175
|
+
const zone = await findZone(config, hostname);
|
|
176
|
+
const [transformations, domains, rules, tieredCache] = await Promise.all([
|
|
177
|
+
cf<{ value?: string }>(config, `/zones/${zone.id}/settings/transformations`, {}, "transformations").then((r) => r.result?.value ?? null),
|
|
178
|
+
cf<{ domains?: CustomDomain[] }>(config, `/accounts/${config.accountId}/r2/buckets/${encodeURIComponent(target.bucket)}/domains/custom`, {}, "domain").then(
|
|
179
|
+
(r) => r.result?.domains ?? [],
|
|
180
|
+
),
|
|
181
|
+
readRules(config, zone.id),
|
|
182
|
+
// Read only to show the step; a token without the permission still gets the rest of the setup.
|
|
183
|
+
cf<{ value?: string }>(config, `/zones/${zone.id}/cache/tiered_cache_smart_topology_enable`, {}, "tieredCache")
|
|
184
|
+
.then((r) => r.result?.value ?? null)
|
|
185
|
+
.catch(() => null),
|
|
186
|
+
]);
|
|
187
|
+
const state: SetupState = { transformations, domains, rules, tieredCache };
|
|
188
|
+
return { zone, state, plan: planMediaHostSetup(state, { host: hostname, zoneName: zone.name, bucket: target.bucket }) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface StepResult {
|
|
192
|
+
id: string;
|
|
193
|
+
ok: boolean;
|
|
194
|
+
message: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Apply the steps that need it, in order; stops at the first failure. */
|
|
198
|
+
export async function applySetup(config: CloudflareConfig, target: SetupTarget): Promise<{ zone: string; results: StepResult[] }> {
|
|
199
|
+
const { zone, plan } = await planSetup(config, target);
|
|
200
|
+
const bucket = encodeURIComponent(target.bucket);
|
|
201
|
+
const results: StepResult[] = [];
|
|
202
|
+
for (const step of plan.steps) {
|
|
203
|
+
if (step.action === "none") {
|
|
204
|
+
results.push({ id: step.id, ok: true, message: step.label });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
if (step.id === "transformations") {
|
|
209
|
+
await cf(config, `/zones/${zone.id}/settings/transformations`, { method: "PATCH", body: JSON.stringify({ value: "on" }) }, "transformations");
|
|
210
|
+
results.push({ id: step.id, ok: true, message: `Turned on Image Transformations for ${zone.name}.` });
|
|
211
|
+
} else if (step.id === "domain" && step.action === "add") {
|
|
212
|
+
await cf(
|
|
213
|
+
config,
|
|
214
|
+
`/accounts/${config.accountId}/r2/buckets/${bucket}/domains/custom`,
|
|
215
|
+
{ method: "POST", body: JSON.stringify({ domain: plan.host, zoneId: zone.id, enabled: true, minTLS: "1.2" }) },
|
|
216
|
+
"domain",
|
|
217
|
+
);
|
|
218
|
+
results.push({ id: step.id, ok: true, message: `Connected ${plan.host} to ${target.bucket}. Its certificate can take a few minutes.` });
|
|
219
|
+
} else if (step.id === "domain") {
|
|
220
|
+
await cf(
|
|
221
|
+
config,
|
|
222
|
+
`/accounts/${config.accountId}/r2/buckets/${bucket}/domains/custom/${encodeURIComponent(plan.host)}`,
|
|
223
|
+
{ method: "PUT", body: JSON.stringify({ enabled: true, minTLS: "1.2" }) },
|
|
224
|
+
"domain",
|
|
225
|
+
);
|
|
226
|
+
results.push({ id: step.id, ok: true, message: `Turned on ${plan.host} for ${target.bucket}.` });
|
|
227
|
+
} else if (step.id === "rules") {
|
|
228
|
+
await cf(
|
|
229
|
+
config,
|
|
230
|
+
`/zones/${zone.id}/rulesets/phases/http_request_transform/entrypoint`,
|
|
231
|
+
{ method: "PUT", body: JSON.stringify({ rules: plan.rules }) },
|
|
232
|
+
"rules",
|
|
233
|
+
);
|
|
234
|
+
results.push({ id: step.id, ok: true, message: `Saved the URL rewrite rules for ${plan.host}.` });
|
|
235
|
+
} else if (step.id === "tieredCache") {
|
|
236
|
+
await cf(config, `/zones/${zone.id}/cache/tiered_cache_smart_topology_enable`, { method: "PATCH", body: JSON.stringify({ value: "on" }) }, "tieredCache");
|
|
237
|
+
results.push({ id: step.id, ok: true, message: `Turned on Smart Tiered Cache for ${zone.name}.` });
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
results.push({ id: step.id, ok: false, message: error instanceof Error ? error.message : "Failed." });
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { zone: zone.name, results };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Live check of a media host ───────────────────────────────────
|
|
248
|
+
|
|
249
|
+
export interface CheckItem {
|
|
250
|
+
id: "reachable" | "original" | "resize" | "cached";
|
|
251
|
+
ok: boolean;
|
|
252
|
+
label: string;
|
|
253
|
+
detail: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Pure: turn the two fetches' outcomes into check items (exported for tests). */
|
|
257
|
+
export function checkItems(input: {
|
|
258
|
+
file: string;
|
|
259
|
+
original: { status: number; type: string } | { error: string };
|
|
260
|
+
resized: { status: number; type: string; cfResized: string | null; cache: string | null } | { error: string };
|
|
261
|
+
}): CheckItem[] {
|
|
262
|
+
const items: CheckItem[] = [];
|
|
263
|
+
const o = input.original;
|
|
264
|
+
const r = input.resized;
|
|
265
|
+
const reached = !("error" in o) || !("error" in r);
|
|
266
|
+
items.push({
|
|
267
|
+
id: "reachable",
|
|
268
|
+
ok: reached,
|
|
269
|
+
label: "Host reachable",
|
|
270
|
+
detail: reached ? "The media host answered." : `Couldn't connect: ${"error" in o ? o.error : ""}. Check the custom domain on the bucket and its DNS.`,
|
|
271
|
+
});
|
|
272
|
+
const originalOk = !("error" in o) && o.status === 200 && o.type.startsWith("image/");
|
|
273
|
+
items.push({
|
|
274
|
+
id: "original",
|
|
275
|
+
ok: originalOk,
|
|
276
|
+
label: "Original served",
|
|
277
|
+
detail:
|
|
278
|
+
"error" in o
|
|
279
|
+
? "No answer."
|
|
280
|
+
: originalOk
|
|
281
|
+
? `${input.file} (${o.type}).`
|
|
282
|
+
: o.status === 404
|
|
283
|
+
? `${input.file} wasn't found (404). Is the custom domain on the bucket bound as MEDIA?`
|
|
284
|
+
: `Answered ${o.status}${o.type ? ` (${o.type})` : ""}.`,
|
|
285
|
+
});
|
|
286
|
+
const resizeOk = !("error" in r) && r.status === 200 && (Boolean(r.cfResized) || r.type.startsWith("image/"));
|
|
287
|
+
items.push({
|
|
288
|
+
id: "resize",
|
|
289
|
+
ok: resizeOk,
|
|
290
|
+
label: "Resize works",
|
|
291
|
+
detail:
|
|
292
|
+
"error" in r
|
|
293
|
+
? "No answer."
|
|
294
|
+
: resizeOk
|
|
295
|
+
? `/s/64x64/ answered ${r.type || "an image"}${r.cfResized ? " (resized by Cloudflare)" : ""}.`
|
|
296
|
+
: r.status === 404
|
|
297
|
+
? originalOk
|
|
298
|
+
? "/s/64x64/ wasn't found (404) from inside the Worker. A check from the Worker isn't conclusive for a media host on the site's own zone, so this alone doesn't mean the URL rewrite rules are missing: the Clean Image URLs page checks again from your browser."
|
|
299
|
+
: "/s/64x64/ wasn't found (404): the URL rewrite rules are missing, or don't match this host."
|
|
300
|
+
: `/s/64x64/ answered ${r.status}. Are Image Transformations on for the zone?`,
|
|
301
|
+
});
|
|
302
|
+
const cache = "error" in r ? null : (r.cache ?? "").toUpperCase();
|
|
303
|
+
const cached = cache === "HIT" || cache === "REVALIDATED" || cache === "STALE" || cache === "UPDATING";
|
|
304
|
+
items.push({
|
|
305
|
+
id: "cached",
|
|
306
|
+
ok: cached,
|
|
307
|
+
label: "Cached at the edge",
|
|
308
|
+
detail: cache ? `Second request: cf-cache-status ${cache}.` : "No cf-cache-status header (normal for the first few requests after setup).",
|
|
309
|
+
});
|
|
310
|
+
return items;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Fetch a file and a 64×64 copy from the media host (the copy twice, to see the cache). */
|
|
314
|
+
export async function checkMediaHost(host: string, file: string, doFetch: Fetch = fetch): Promise<CheckItem[]> {
|
|
315
|
+
const accept = "image/avif,image/webp,image/*,*/*;q=0.8";
|
|
316
|
+
const get = async (url: string) => {
|
|
317
|
+
try {
|
|
318
|
+
const res = await doFetch(url, { headers: { Accept: accept }, redirect: "follow" });
|
|
319
|
+
await res.body?.cancel().catch(() => undefined);
|
|
320
|
+
return { status: res.status, type: (res.headers.get("content-type") ?? "").split(";")[0].trim(), cfResized: res.headers.get("cf-resized"), cache: res.headers.get("cf-cache-status") };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return { error: error instanceof Error ? error.message : "network error" };
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const original = await get(`${host}/${file}`);
|
|
326
|
+
const first = await get(`${host}/s/64x64/${file}`);
|
|
327
|
+
const resized = "error" in first || first.status !== 200 ? first : await get(`${host}/s/64x64/${file}`);
|
|
328
|
+
return checkItems({ file, original, resized });
|
|
329
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean image URLs: resized copies of media-library images at
|
|
3
|
+
*
|
|
4
|
+
* /media/<file id>-<width>x<height>.<format> (cropped to fill)
|
|
5
|
+
* /media/<file id>-<width>w.<format> (width only, keeps the ratio)
|
|
6
|
+
*
|
|
7
|
+
* instead of Astro's /_image?href=…&w=…&h=… endpoint. Pure helpers (no
|
|
8
|
+
* imports) so tests can load them; the middleware is in pack.ts.
|
|
9
|
+
*
|
|
10
|
+
* With a media host configured (`images: { cdn: "https://media.example.com" }`:
|
|
11
|
+
* an R2 custom domain on the media bucket, with Cloudflare Image
|
|
12
|
+
* Transformations and the zone's /s/ rewrite rules), URLs point there instead
|
|
13
|
+
* and never touch the Worker:
|
|
14
|
+
*
|
|
15
|
+
* https://media.example.com/<file> the original file
|
|
16
|
+
* https://media.example.com/s/<w>x<h>/<file> cropped to fill
|
|
17
|
+
* https://media.example.com/s/<w>/<file> width only, keeps the ratio
|
|
18
|
+
*
|
|
19
|
+
* The media host picks WebP or AVIF for browsers that accept them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const IMAGE_PATH = "/media/";
|
|
23
|
+
/**
|
|
24
|
+
* Media library files: /_emdash/api/media/file/<key> (EmDash's media route),
|
|
25
|
+
* where the key is <stem>.<ext>. The stem is EmDash's file ULID (not the media
|
|
26
|
+
* id), and since EmDash 1.1 uploads it can hold a dot (<ulid>.<attempt ulid>),
|
|
27
|
+
* so a stem is anything up to the last dot.
|
|
28
|
+
*/
|
|
29
|
+
const MEDIA_FILE = /^(?:https?:\/\/[^/]+)?\/_emdash\/api\/media\/file\/([A-Za-z0-9_.-]+)\.([a-z0-9]{2,5})(?:[?#].*)?$/i;
|
|
30
|
+
const CLEAN = /^\/media\/([A-Za-z0-9_.-]+)-(?:(\d{1,4})x(\d{1,4})|(\d{1,4})w)\.(webp|avif|jpe?g|png)$/i;
|
|
31
|
+
export const MAX_DIMENSION = 2560;
|
|
32
|
+
/**
|
|
33
|
+
* Worker-route sizes are multiples of this many pixels, so anyone requesting
|
|
34
|
+
* /media/ URLs can't make the Worker run (and bill) a new transform for every
|
|
35
|
+
* pixel. 10 keeps the sizes themes ask for (50, 100, 400, 640, 800, 1200x630)
|
|
36
|
+
* exact; anything else rounds up.
|
|
37
|
+
*/
|
|
38
|
+
export const SIZE_STEP = 10;
|
|
39
|
+
|
|
40
|
+
/** Round a size up to the Worker route's grid (at most MAX_DIMENSION). */
|
|
41
|
+
export function snapDimension(n: number): number {
|
|
42
|
+
return Math.min(MAX_DIMENSION, Math.ceil(n / SIZE_STEP) * SIZE_STEP);
|
|
43
|
+
}
|
|
44
|
+
export const FORMATS: Record<string, string> = { webp: "image/webp", avif: "image/avif", jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png" };
|
|
45
|
+
|
|
46
|
+
/** MIME types of original files by extension (for og:image:type on media-host URLs). */
|
|
47
|
+
export const EXT_MIME: Record<string, string> = { ...FORMATS, gif: "image/gif", svg: "image/svg+xml" };
|
|
48
|
+
/** Originals the media host (Cloudflare Image Transformations) can resize. */
|
|
49
|
+
const CDN_RESIZABLE = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "heic"]);
|
|
50
|
+
|
|
51
|
+
/** The MIME type of a file extension, or undefined when unknown. */
|
|
52
|
+
export function mimeForExt(ext: string | null | undefined): string | undefined {
|
|
53
|
+
return EXT_MIME[String(ext ?? "").toLowerCase()];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a media host to its https origin ("media.example.com",
|
|
58
|
+
* "https://media.example.com/" → "https://media.example.com"). Null for
|
|
59
|
+
* anything that isn't a plain https host name (no path, query, user or port
|
|
60
|
+
* other than the default).
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeMediaHost(input: string | null | undefined): string | null {
|
|
63
|
+
let v = String(input ?? "").trim();
|
|
64
|
+
if (!v) return null;
|
|
65
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) v = `https://${v}`;
|
|
66
|
+
v = v.replace(/\/+$/, "");
|
|
67
|
+
const m = /^https:\/\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)$/i.exec(v);
|
|
68
|
+
return m ? `https://${m[1].toLowerCase()}` : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The media host set in astro.config.mjs (`images.cdn`), and the one saved on the Clean Image URLs page (wins when set). */
|
|
72
|
+
let optionBase: string | null = null;
|
|
73
|
+
let savedBase: string | null = null;
|
|
74
|
+
|
|
75
|
+
/** Set (or clear) the media host from the plugin option. Anything but an https origin is ignored. */
|
|
76
|
+
export function setImageCdn(url: string | null | undefined): void {
|
|
77
|
+
optionBase = normalizeMediaHost(url);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Set (or clear) the media host saved in the admin. It overrides the plugin option while set. */
|
|
81
|
+
export function setSavedImageCdn(url: string | null | undefined): void {
|
|
82
|
+
savedBase = normalizeMediaHost(url);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The media host in use (https origin, no trailing slash), or null for the Worker's /media/ route. */
|
|
86
|
+
export function imageCdn(): string | null {
|
|
87
|
+
return savedBase ?? optionBase;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add `Link: <media host>; rel=preconnect` to a page (HTML) response, so the
|
|
92
|
+
* browser opens the connection to the media host (DNS, TCP, TLS) while the
|
|
93
|
+
* page is still arriving instead of when it reaches the first image. No
|
|
94
|
+
* `crossorigin`: images are fetched without CORS, so that's the connection
|
|
95
|
+
* they use. Returns the response unchanged without a host, for anything but
|
|
96
|
+
* HTML, or when it already has a preconnect to the host.
|
|
97
|
+
*/
|
|
98
|
+
export function withMediaPreconnect(response: Response, host: string | null = imageCdn()): Response {
|
|
99
|
+
if (!host || !(response.headers.get("content-type") ?? "").startsWith("text/html")) return response;
|
|
100
|
+
const link = response.headers.get("link") ?? "";
|
|
101
|
+
if (link.includes(`<${host}>`) && /rel="?preconnect/i.test(link)) return response;
|
|
102
|
+
const value = `<${host}>; rel=preconnect`;
|
|
103
|
+
try {
|
|
104
|
+
response.headers.append("link", value);
|
|
105
|
+
return response;
|
|
106
|
+
} catch {
|
|
107
|
+
// Immutable headers (a response passed through from fetch): copy it.
|
|
108
|
+
const copy = new Response(response.body, response);
|
|
109
|
+
copy.headers.append("link", value);
|
|
110
|
+
return copy;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Where the media host comes from: the admin setting, the plugin option, or nowhere. */
|
|
115
|
+
export function imageCdnSource(): { host: string | null; option: string | null; saved: string | null } {
|
|
116
|
+
return { host: imageCdn(), option: optionBase, saved: savedBase };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ImageRequest {
|
|
120
|
+
id: string;
|
|
121
|
+
width: number;
|
|
122
|
+
height?: number;
|
|
123
|
+
format: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The file id and extension of a media-library URL, or null for anything else. */
|
|
127
|
+
export function mediaFile(src: string | null | undefined): { id: string; ext: string } | null {
|
|
128
|
+
const m = MEDIA_FILE.exec(String(src ?? "").trim());
|
|
129
|
+
return m ? { id: m[1], ext: m[2].toLowerCase() } : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The original file on the media host, or null when there's no host or `src` isn't a media-library file. */
|
|
133
|
+
export function cdnOriginalUrl(src: string | null | undefined): string | null {
|
|
134
|
+
const cdn = imageCdn();
|
|
135
|
+
if (!cdn) return null;
|
|
136
|
+
const file = mediaFile(src);
|
|
137
|
+
if (file) return `${cdn}/${file.id}.${file.ext}`;
|
|
138
|
+
// Already on the media host: its original.
|
|
139
|
+
const onHost = parseCdnUrl(String(src ?? "").trim());
|
|
140
|
+
return onHost ? `${cdn}/${onHost.id}.${onHost.ext}` : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build a clean URL; null when `src` isn't a media-library file or the size is
|
|
145
|
+
* out of range. On the media host when one is set (format chosen by the host),
|
|
146
|
+
* otherwise the Worker's /media/ route.
|
|
147
|
+
*/
|
|
148
|
+
export function cleanImagePath(src: string | null | undefined, options: { width: number; height?: number; format?: string }): string | null {
|
|
149
|
+
const file = mediaFile(src);
|
|
150
|
+
if (!file) return null;
|
|
151
|
+
const width = Math.round(options.width);
|
|
152
|
+
const height = options.height === undefined ? undefined : Math.round(options.height);
|
|
153
|
+
if (!(width >= 1 && width <= MAX_DIMENSION) || (height !== undefined && !(height >= 1 && height <= MAX_DIMENSION))) return null;
|
|
154
|
+
const cdn = imageCdn();
|
|
155
|
+
if (cdn) {
|
|
156
|
+
// Cloudflare's resizer reads JPEG, PNG, GIF, WebP, SVG and HEIC, not AVIF (it answers 415).
|
|
157
|
+
if (!CDN_RESIZABLE.has(file.ext)) return null;
|
|
158
|
+
return `${cdn}/s/${height === undefined ? width : `${width}x${height}`}/${file.id}.${file.ext}`;
|
|
159
|
+
}
|
|
160
|
+
const format = (options.format ?? "webp").toLowerCase();
|
|
161
|
+
if (!FORMATS[format]) return null;
|
|
162
|
+
return imagePath({ id: file.id, width: snapDimension(width), height: height === undefined ? undefined : snapDimension(height), format });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The Worker-route path of a request, as given (no snapping). */
|
|
166
|
+
function imagePath(request: ImageRequest): string {
|
|
167
|
+
return `${IMAGE_PATH}${request.id}-${request.height === undefined ? `${request.width}w` : `${request.width}x${request.height}`}.${request.format}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The on-grid path for a Worker-route request whose size is off the grid
|
|
172
|
+
* (see SIZE_STEP), or null when it's already on it. The middleware answers
|
|
173
|
+
* off-grid requests with a redirect there instead of resizing.
|
|
174
|
+
*/
|
|
175
|
+
export function snappedImagePath(request: ImageRequest): string | null {
|
|
176
|
+
const width = snapDimension(request.width);
|
|
177
|
+
const height = request.height === undefined ? undefined : snapDimension(request.height);
|
|
178
|
+
if (width === request.width && height === request.height) return null;
|
|
179
|
+
return imagePath({ ...request, width, height });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parse a clean URL path; null when it isn't one (or the size is out of range). */
|
|
183
|
+
export function parseImagePath(pathname: string): ImageRequest | null {
|
|
184
|
+
const m = CLEAN.exec(pathname);
|
|
185
|
+
if (!m) return null;
|
|
186
|
+
const width = Number(m[2] ?? m[4]);
|
|
187
|
+
const height = m[3] ? Number(m[3]) : undefined;
|
|
188
|
+
if (!(width >= 1 && width <= MAX_DIMENSION) || (height !== undefined && !(height >= 1 && height <= MAX_DIMENSION))) return null;
|
|
189
|
+
return { id: m[1], width, height, format: m[5].toLowerCase() };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse a media-host URL (absolute, on the configured host): the file and the
|
|
194
|
+
* requested size (none for the original). Null for anything else.
|
|
195
|
+
*/
|
|
196
|
+
export function parseCdnUrl(url: string | null | undefined): { id: string; ext: string; width?: number; height?: number } | null {
|
|
197
|
+
const cdn = imageCdn();
|
|
198
|
+
const value = String(url ?? "");
|
|
199
|
+
if (!cdn || !value.startsWith(`${cdn}/`)) return null;
|
|
200
|
+
const path = value.slice(cdn.length).split(/[?#]/)[0];
|
|
201
|
+
const m = /^\/(?:s\/(\d{1,4})(?:x(\d{1,4}))?\/)?([A-Za-z0-9_.-]+)\.([a-z0-9]{2,5})$/i.exec(path);
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
const width = m[1] ? Number(m[1]) : undefined;
|
|
204
|
+
const height = m[2] ? Number(m[2]) : undefined;
|
|
205
|
+
if ((width !== undefined && !(width >= 1 && width <= MAX_DIMENSION)) || (height !== undefined && !(height >= 1 && height <= MAX_DIMENSION))) return null;
|
|
206
|
+
return { id: m[3], ext: m[4].toLowerCase(), width, height };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A media-library file's bucket key as stored by EmDash (<id>.<ext>). */
|
|
210
|
+
const KEY = /^[A-Za-z0-9_.-]+\.[a-z0-9]{2,5}$/i;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Where an old Worker-route URL (/media/<id>-<w>x<h>.<format>) lives on the
|
|
214
|
+
* media host, given the file's bucket key: the same size, format chosen by
|
|
215
|
+
* the host. Null without a media host or for a malformed key.
|
|
216
|
+
*/
|
|
217
|
+
export function cdnRedirectUrl(request: Pick<ImageRequest, "width" | "height">, key: string): string | null {
|
|
218
|
+
const cdn = imageCdn();
|
|
219
|
+
if (!cdn || !KEY.test(key)) return null;
|
|
220
|
+
return `${cdn}/s/${request.height === undefined ? request.width : `${request.width}x${request.height}`}/${key}`;
|
|
221
|
+
}
|