@coywolf/emdash 0.0.0-stage → 0.35.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stopgap renders: a page that showed an image (or video poster) from a
|
|
3
|
+
* temporary source, because its stored copy isn't made yet, is marked so the
|
|
4
|
+
* pack middleware caches it for minutes, not days. The next render, once the
|
|
5
|
+
* copy exists, uses it. No imports, so tests can load it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Astro.locals key: this request rendered media whose stored copy is still being made. */
|
|
9
|
+
export const PENDING_MEDIA_LOCAL = "__cwPendingPoster";
|
|
10
|
+
/**
|
|
11
|
+
* Per isolate: stopgap renders for callers that didn't pass `locals`. The
|
|
12
|
+
* middleware compares it before and after a render; a concurrent render in the
|
|
13
|
+
* same isolate can shorten another page's lifetime too, which only costs a
|
|
14
|
+
* re-render.
|
|
15
|
+
*/
|
|
16
|
+
let pendingRenders = 0;
|
|
17
|
+
|
|
18
|
+
/** Note that this render used a stopgap for an image or poster whose stored copy isn't made yet. */
|
|
19
|
+
export function markPendingMedia(locals?: object | null): void {
|
|
20
|
+
if (locals && typeof locals === "object") (locals as Record<string, unknown>)[PENDING_MEDIA_LOCAL] = true;
|
|
21
|
+
else pendingRenders++;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How many stopgap renders this isolate has made without `locals` (see markPendingMedia). */
|
|
25
|
+
export function pendingMediaRenders(): number {
|
|
26
|
+
return pendingRenders;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether a request rendered a stopgap: flagged on its locals, or counted since `before`. */
|
|
30
|
+
export function renderedPendingMedia(locals: unknown, before: number): boolean {
|
|
31
|
+
return Boolean((locals as Record<string, unknown> | undefined)?.[PENDING_MEDIA_LOCAL]) || pendingRenders !== before;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reset the count (tests). */
|
|
35
|
+
export function resetPendingMedia(): void {
|
|
36
|
+
pendingRenders = 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media host saved on the Clean Image URLs page, read outside the plugin
|
|
3
|
+
* context (theme code, middleware, the page:metadata hook) from D1 in the
|
|
4
|
+
* feature switches' query, sharing their per-isolate cache. Applied to the
|
|
5
|
+
* pure helpers in lib.ts, where it overrides the astro.config.mjs option.
|
|
6
|
+
*/
|
|
7
|
+
import { invalidateFeatures, readSiteSetting, registerSiteSetting, rememberSiteSetting, siteSetting } from "../core/features.js";
|
|
8
|
+
import { setSavedImageCdn } from "./lib.js";
|
|
9
|
+
|
|
10
|
+
/** Plain settings (edited on the Clean Image URLs page). The API token is a secret, declared in src/core/secrets.ts. */
|
|
11
|
+
export const IMAGES_SETTINGS = {
|
|
12
|
+
host: "imagesMediaHost",
|
|
13
|
+
accountId: "imagesAccountId",
|
|
14
|
+
bucket: "imagesBucketName",
|
|
15
|
+
token: "imagesApiToken",
|
|
16
|
+
/** Make stored sizes for existing images too (the backfill). Off until an admin turns it on: it costs transformations. */
|
|
17
|
+
bulk: "imagesVariantsBulk",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
registerSiteSetting(IMAGES_SETTINGS.host);
|
|
21
|
+
registerSiteSetting(IMAGES_SETTINGS.bulk);
|
|
22
|
+
let database = "DB";
|
|
23
|
+
|
|
24
|
+
export function configureMediaHostDatabase(name: string | undefined): void {
|
|
25
|
+
database = name ?? "DB";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Forget the cached value (call after saving it), optionally applying the new one right away. */
|
|
29
|
+
export function invalidateMediaHost(value?: string | null): void {
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
invalidateFeatures();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
setSavedImageCdn(value);
|
|
35
|
+
rememberSiteSetting(IMAGES_SETTINGS.host, value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Apply the saved media host (from the feature switches' 30-second cache). Keeps the last value if D1 can't be read. */
|
|
39
|
+
export async function refreshMediaHost(): Promise<void> {
|
|
40
|
+
const read = await readSiteSetting(IMAGES_SETTINGS.host, database).catch(() => null);
|
|
41
|
+
// Not on Cloudflare, or no database: the option (if any) stays in use.
|
|
42
|
+
if (read) setSavedImageCdn(typeof read.value === "string" ? read.value : null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the site turned on stored sizes for existing images (read with the feature switches: no extra query). */
|
|
46
|
+
export async function variantsBulkOn(): Promise<boolean> {
|
|
47
|
+
return (await siteSetting<boolean>(IMAGES_SETTINGS.bulk, database).catch(() => null)) === true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media host setup on Cloudflare: the zone's URL-rewrite rules and a pure
|
|
3
|
+
* planner that compares what's there with what's needed. No I/O and no
|
|
4
|
+
* imports, so tests can load it directly; the API calls are in cloudflare.ts.
|
|
5
|
+
*
|
|
6
|
+
* The three steps, each skipped when already done:
|
|
7
|
+
* 1. Image Transformations on for the zone (zone setting `transformations`).
|
|
8
|
+
* 2. The media host as a custom domain on the media bucket (R2).
|
|
9
|
+
* 3. Two URL-rewrite rules (phase http_request_transform) mapping
|
|
10
|
+
* /s/<w>x<h>/<file> and /s/<w>/<file> to /cdn-cgi/image/… on that host.
|
|
11
|
+
* Other rules in the phase are kept as they are.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A rule in a ruleset, as the API returns it (only the fields we read or send back). */
|
|
15
|
+
export interface RulesetRule {
|
|
16
|
+
id?: string;
|
|
17
|
+
ref?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
expression?: string;
|
|
20
|
+
action?: string;
|
|
21
|
+
action_parameters?: Record<string, unknown>;
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
logging?: unknown;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CustomDomain {
|
|
28
|
+
domain: string;
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
zoneId?: string;
|
|
31
|
+
zoneName?: string;
|
|
32
|
+
status?: { ownership?: string; ssl?: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What's on Cloudflare now. `rules` is null when the zone has no http_request_transform ruleset yet. */
|
|
36
|
+
export interface SetupState {
|
|
37
|
+
transformations: string | null;
|
|
38
|
+
domains: CustomDomain[];
|
|
39
|
+
rules: RulesetRule[] | null;
|
|
40
|
+
/** Smart Tiered Cache ("on"/"off"), or null when it couldn't be read (the step then tries to turn it on). */
|
|
41
|
+
tieredCache?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type StepAction = "none" | "enable" | "add" | "update";
|
|
45
|
+
|
|
46
|
+
export interface SetupStep {
|
|
47
|
+
id: "transformations" | "domain" | "rules" | "tieredCache";
|
|
48
|
+
action: StepAction;
|
|
49
|
+
/** Plain-language summary, e.g. "Turn on Image Transformations for example.com". */
|
|
50
|
+
label: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SetupPlan {
|
|
54
|
+
host: string;
|
|
55
|
+
steps: SetupStep[];
|
|
56
|
+
/** The full rule list to PUT when the rules step isn't "none" (unrelated rules first, in their order). */
|
|
57
|
+
rules: RulesetRule[];
|
|
58
|
+
/** True when nothing needs to change. */
|
|
59
|
+
done: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TAG = "(Coywolf Pack clean image URLs)";
|
|
63
|
+
|
|
64
|
+
/** The two URL-rewrite rules for a media host name (e.g. "media.example.com"). */
|
|
65
|
+
export function mediaHostRules(hostname: string): RulesetRule[] {
|
|
66
|
+
const host = `http.host eq "${hostname}"`;
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
description: `${hostname}: /s/<W>x<H>/<file> → cropped resize ${TAG}`,
|
|
70
|
+
expression: `(${host} and http.request.uri.path wildcard "/s/*x*/*")`,
|
|
71
|
+
action: "rewrite",
|
|
72
|
+
action_parameters: {
|
|
73
|
+
uri: { path: { expression: 'wildcard_replace(http.request.uri.path, "/s/*x*/*", "/cdn-cgi/image/width=${1},height=${2},fit=cover,format=auto,quality=85/${3}")' } },
|
|
74
|
+
},
|
|
75
|
+
enabled: true,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
description: `${hostname}: /s/<W>/<file> → resize to width ${TAG}`,
|
|
79
|
+
expression: `(${host} and http.request.uri.path wildcard "/s/*/*" and not http.request.uri.path wildcard "/s/*x*/*")`,
|
|
80
|
+
action: "rewrite",
|
|
81
|
+
action_parameters: {
|
|
82
|
+
uri: { path: { expression: 'wildcard_replace(http.request.uri.path, "/s/*/*", "/cdn-cgi/image/width=${1},format=auto,quality=85/${2}")' } },
|
|
83
|
+
},
|
|
84
|
+
enabled: true,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Is this one of our rules for `hostname` (matched by description)? */
|
|
90
|
+
export function isOurRule(rule: RulesetRule, hostname: string): boolean {
|
|
91
|
+
const d = rule.description ?? "";
|
|
92
|
+
return d.startsWith(`${hostname}: `) && d.endsWith(TAG);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Fields the API accepts back for a rule we keep (it rejects or ignores read-only ones like version and last_updated). */
|
|
96
|
+
const KEEP_FIELDS = ["id", "ref", "action", "action_parameters", "description", "enabled", "expression", "logging", "ratelimit", "exposed_credential_check"];
|
|
97
|
+
|
|
98
|
+
export function sendableRule(rule: RulesetRule): RulesetRule {
|
|
99
|
+
const out: RulesetRule = {};
|
|
100
|
+
for (const key of KEEP_FIELDS) if (rule[key] !== undefined) out[key] = rule[key];
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pathExpression = (rule: RulesetRule) =>
|
|
105
|
+
((rule.action_parameters as { uri?: { path?: { expression?: string } } } | undefined)?.uri?.path?.expression ?? "").trim();
|
|
106
|
+
|
|
107
|
+
function sameRule(a: RulesetRule, b: RulesetRule): boolean {
|
|
108
|
+
return (
|
|
109
|
+
a.description === b.description &&
|
|
110
|
+
(a.expression ?? "").trim() === (b.expression ?? "").trim() &&
|
|
111
|
+
a.action === b.action &&
|
|
112
|
+
pathExpression(a) === pathExpression(b) &&
|
|
113
|
+
a.enabled !== false
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The host name of a media host origin or bare name ("https://media.example.com" → "media.example.com"). */
|
|
118
|
+
export function hostnameOf(host: string): string {
|
|
119
|
+
return host.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Zone names to try for a host, most specific first: media.blog.example.co.uk →
|
|
124
|
+
* blog.example.co.uk, example.co.uk, co.uk. (The API answers only for zones
|
|
125
|
+
* in the account, so the first match is the zone.)
|
|
126
|
+
*/
|
|
127
|
+
export function zoneCandidates(hostname: string): string[] {
|
|
128
|
+
const labels = hostname.split(".");
|
|
129
|
+
const out: string[] = [];
|
|
130
|
+
for (let i = 1; i < labels.length - 1; i++) out.push(labels.slice(i).join("."));
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Compare the current state with what the media host needs. */
|
|
135
|
+
export function planMediaHostSetup(state: SetupState, input: { host: string; zoneName: string; bucket: string }): SetupPlan {
|
|
136
|
+
const hostname = hostnameOf(input.host);
|
|
137
|
+
const steps: SetupStep[] = [];
|
|
138
|
+
|
|
139
|
+
steps.push(
|
|
140
|
+
state.transformations === "on"
|
|
141
|
+
? { id: "transformations", action: "none", label: `Image Transformations are already on for ${input.zoneName}.` }
|
|
142
|
+
: { id: "transformations", action: "enable", label: `Turn on Image Transformations for ${input.zoneName}.` },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const domain = state.domains.find((d) => d.domain.toLowerCase() === hostname);
|
|
146
|
+
steps.push(
|
|
147
|
+
!domain
|
|
148
|
+
? { id: "domain", action: "add", label: `Connect ${hostname} to the R2 bucket ${input.bucket} as a custom domain.` }
|
|
149
|
+
: domain.enabled === false
|
|
150
|
+
? { id: "domain", action: "enable", label: `Turn on ${hostname}, already connected to the R2 bucket ${input.bucket}.` }
|
|
151
|
+
: { id: "domain", action: "none", label: `${hostname} is already connected to the R2 bucket ${input.bucket}.` },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const wanted = mediaHostRules(hostname);
|
|
155
|
+
const existing = state.rules ?? [];
|
|
156
|
+
const ours = existing.filter((r) => isOurRule(r, hostname));
|
|
157
|
+
const upToDate = ours.length === wanted.length && wanted.every((w) => ours.some((o) => sameRule(o, w)));
|
|
158
|
+
let rules: RulesetRule[] = [];
|
|
159
|
+
if (upToDate) {
|
|
160
|
+
steps.push({ id: "rules", action: "none", label: `The 2 URL rewrite rules for ${hostname} are already in place.` });
|
|
161
|
+
} else {
|
|
162
|
+
// Keep every unrelated rule where it is; ours go where the first old one of ours was, or at the end.
|
|
163
|
+
const firstOurs = existing.findIndex((r) => isOurRule(r, hostname));
|
|
164
|
+
const others = existing.filter((r) => !isOurRule(r, hostname)).map(sendableRule);
|
|
165
|
+
const at = firstOurs === -1 ? others.length : existing.slice(0, firstOurs).filter((r) => !isOurRule(r, hostname)).length;
|
|
166
|
+
rules = [...others.slice(0, at), ...wanted, ...others.slice(at)];
|
|
167
|
+
steps.push(
|
|
168
|
+
ours.length
|
|
169
|
+
? { id: "rules", action: "update", label: `Update the URL rewrite rules for ${hostname} (other rules stay as they are).` }
|
|
170
|
+
: {
|
|
171
|
+
id: "rules",
|
|
172
|
+
action: "add",
|
|
173
|
+
label: `Add 2 URL rewrite rules for ${hostname}${others.length ? ` (the zone's ${others.length} other rule${others.length === 1 ? "" : "s"} stay as they are)` : ""}.`,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Smart Tiered Cache: a data center that hasn't cached an image yet asks a nearby upper-tier
|
|
179
|
+
// Cloudflare cache (placed near the R2 bucket) before going back to R2. Free on every plan.
|
|
180
|
+
// (Pages don't need it: Workers Cache is always tiered.) Only ever turned on, never off.
|
|
181
|
+
steps.push(
|
|
182
|
+
state.tieredCache === "on"
|
|
183
|
+
? { id: "tieredCache", action: "none", label: `Smart Tiered Cache is already on for ${input.zoneName}.` }
|
|
184
|
+
: {
|
|
185
|
+
id: "tieredCache",
|
|
186
|
+
action: "enable",
|
|
187
|
+
label: `Turn on Smart Tiered Cache for ${input.zoneName}, so a data center that hasn't cached an image yet gets it from a nearby Cloudflare cache instead of R2.`,
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return { host: hostname, steps, rules, done: steps.every((s) => s.action === "none") };
|
|
192
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost estimate for stored image sizes (Clean Image URLs page): what making
|
|
3
|
+
* them for the existing library costs once, what storing them costs a month,
|
|
4
|
+
* and what serving the same sizes through the media host's /s/ resizing would
|
|
5
|
+
* cost a month instead. Pure functions (the admin page computes everything
|
|
6
|
+
* after one fetch); they plan each image with the generator's own rules.
|
|
7
|
+
*
|
|
8
|
+
* Cloudflare bills a "unique transformation" once per calendar month per
|
|
9
|
+
* image and set of options: 5,000 free a month, then $0.50 per 1,000 (Images
|
|
10
|
+
* Paid). For /s/ URLs the format is chosen per visitor (format=auto), which
|
|
11
|
+
* counts once, so each size of each image costs one transformation a month.
|
|
12
|
+
* A stored size is made once with explicit formats: WebP and AVIF count as
|
|
13
|
+
* two (AVIF isn't made beyond 1,200 pixels, so those sizes count once).
|
|
14
|
+
*/
|
|
15
|
+
import { avifEncoded, eligible, plannedWidths, scaledHeight } from "./variants.js";
|
|
16
|
+
|
|
17
|
+
export const PRICE_PER_TRANSFORM = 0.5 / 1000;
|
|
18
|
+
export const FREE_TRANSFORMS = 5000;
|
|
19
|
+
/** R2 Standard storage, per GB-month (10 GB a month are free). */
|
|
20
|
+
export const R2_PER_GB_MONTH = 0.015;
|
|
21
|
+
/** Rough average file size of one stored copy (WebP or AVIF) at each width, in bytes. */
|
|
22
|
+
export const AVERAGE_FILE_BYTES: Record<number, number> = { 400: 25_000, 640: 50_000, 800: 75_000, 1200: 140_000, 1600: 230_000 };
|
|
23
|
+
/** Different sizes of one image a visitor's device typically asks for (phones, tablets, desktops). */
|
|
24
|
+
export const SIZES_PER_VISITED_IMAGE = 3;
|
|
25
|
+
/** Share of a site's pages that search engines fetch in a month (an assumption). */
|
|
26
|
+
export const CRAWLED_SHARE = 0.8;
|
|
27
|
+
|
|
28
|
+
/** What storing one image's widths takes: sizes, files, one-time transformations, and bytes. */
|
|
29
|
+
export function planImage(width: number, height: number | null | undefined): { sizes: number; files: number; transforms: number; bytes: number } {
|
|
30
|
+
const widths = plannedWidths(width);
|
|
31
|
+
let transforms = 0;
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
for (const w of widths) {
|
|
34
|
+
transforms += avifEncoded(w, scaledHeight(w, width, height)) ? 2 : 1;
|
|
35
|
+
bytes += 2 * (AVERAGE_FILE_BYTES[w] ?? 100_000);
|
|
36
|
+
}
|
|
37
|
+
return { sizes: widths.length, files: widths.length * 2, transforms, bytes };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface LibraryRow {
|
|
41
|
+
mime_type: string | null;
|
|
42
|
+
width: number | null;
|
|
43
|
+
height: number | null;
|
|
44
|
+
size: number | null;
|
|
45
|
+
/** 1 when the image already has its stored sizes. */
|
|
46
|
+
current: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LibrarySummary {
|
|
50
|
+
/** Images that can get stored sizes. */
|
|
51
|
+
images: number;
|
|
52
|
+
/** Of those, how many already have them. */
|
|
53
|
+
done: number;
|
|
54
|
+
/** Sizes (widths) of all eligible images: what /s/ could be asked for each month. */
|
|
55
|
+
sizes: number;
|
|
56
|
+
/** Files stored for all eligible images (two per size). */
|
|
57
|
+
files: number;
|
|
58
|
+
/** Bytes stored for all eligible images (about). */
|
|
59
|
+
bytes: number;
|
|
60
|
+
/** One-time transformations still to make (images without their sizes yet). */
|
|
61
|
+
transforms: number;
|
|
62
|
+
/** Files still to make. */
|
|
63
|
+
filesLeft: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function summarizeLibrary(rows: LibraryRow[]): LibrarySummary {
|
|
67
|
+
const out: LibrarySummary = { images: 0, done: 0, sizes: 0, files: 0, bytes: 0, transforms: 0, filesLeft: 0 };
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
if (!eligible(row.mime_type, row.width, row.size)) continue;
|
|
70
|
+
const plan = planImage(row.width as number, row.height);
|
|
71
|
+
out.images++;
|
|
72
|
+
out.sizes += plan.sizes;
|
|
73
|
+
out.files += plan.files;
|
|
74
|
+
out.bytes += plan.bytes;
|
|
75
|
+
if (row.current) out.done++;
|
|
76
|
+
else {
|
|
77
|
+
out.transforms += plan.transforms;
|
|
78
|
+
out.filesLeft += plan.files;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Cost of transformations made in one month: `free` of them are free (0 when the month's free ones are already used). */
|
|
85
|
+
export function transformCost(transforms: number, free = FREE_TRANSFORMS): number {
|
|
86
|
+
return Math.max(0, transforms - free) * PRICE_PER_TRANSFORM;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The one-time fee: from "all 5,000 free ones still available" to "none left this month". */
|
|
90
|
+
export function oneTimeCost(transforms: number): { min: number; max: number } {
|
|
91
|
+
return { min: transformCost(transforms), max: transformCost(transforms, 0) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Storage a month for `bytes` (before R2's free 10 GB). */
|
|
95
|
+
export function storageCost(bytes: number): number {
|
|
96
|
+
return (bytes / 1e9) * R2_PER_GB_MONTH;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TrafficInput {
|
|
100
|
+
/** Page views a month. */
|
|
101
|
+
views: number;
|
|
102
|
+
/** Published pages. */
|
|
103
|
+
pages: number;
|
|
104
|
+
/** Images that can get stored sizes. */
|
|
105
|
+
images: number;
|
|
106
|
+
/** Sizes those images have in total (summary.sizes). */
|
|
107
|
+
sizes: number;
|
|
108
|
+
/** Bytes of all stored sizes (summary.bytes). */
|
|
109
|
+
bytes: number;
|
|
110
|
+
/** Search engines fetch most pages each month. */
|
|
111
|
+
crawlers: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface TrafficEstimate {
|
|
115
|
+
/** Different pages visited in a month. */
|
|
116
|
+
pagesVisited: number;
|
|
117
|
+
/** Different images shown in a month. */
|
|
118
|
+
imagesShown: number;
|
|
119
|
+
/** Transformations a month without stored sizes (each size shown, plus one Open Graph image per page). */
|
|
120
|
+
withoutTransforms: number;
|
|
121
|
+
withoutCost: number;
|
|
122
|
+
/** Transformations a month with stored sizes (only the Open Graph image per page). */
|
|
123
|
+
withTransforms: number;
|
|
124
|
+
/** Their cost plus storage. */
|
|
125
|
+
withCost: number;
|
|
126
|
+
/** Saved a month. */
|
|
127
|
+
savings: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A month of traffic, roughly: with V views spread evenly over P pages, about
|
|
132
|
+
* P × (1 − e^(−V/P)) different pages are seen (real traffic favors some pages,
|
|
133
|
+
* so fewer); search engines that crawl the site fetch at least 80% of them.
|
|
134
|
+
* Images are spread evenly over pages; each image shown is asked for in about
|
|
135
|
+
* three sizes (fewer when it has fewer).
|
|
136
|
+
*/
|
|
137
|
+
export function trafficEstimate(input: TrafficInput): TrafficEstimate {
|
|
138
|
+
const pages = Math.max(0, input.pages);
|
|
139
|
+
const views = Math.max(0, input.views);
|
|
140
|
+
let pagesVisited = pages ? pages * (1 - Math.exp(-views / pages)) : 0;
|
|
141
|
+
if (input.crawlers) pagesVisited = Math.max(pagesVisited, CRAWLED_SHARE * pages);
|
|
142
|
+
pagesVisited = Math.round(Math.min(pages, pagesVisited));
|
|
143
|
+
const imagesShown = pages ? Math.round(Math.min(input.images, (input.images * pagesVisited) / pages)) : 0;
|
|
144
|
+
const perImage = input.images ? Math.min(SIZES_PER_VISITED_IMAGE, input.sizes / input.images) : 0;
|
|
145
|
+
const withTransforms = pagesVisited;
|
|
146
|
+
const withoutTransforms = Math.round(imagesShown * perImage) + pagesVisited;
|
|
147
|
+
const withoutCost = transformCost(withoutTransforms);
|
|
148
|
+
const withCost = transformCost(withTransforms) + storageCost(input.bytes);
|
|
149
|
+
return { pagesVisited, imagesShown, withoutTransforms, withoutCost, withTransforms, withCost, savings: withoutCost - withCost };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Months until the one-time fee is paid back by `savings` a month: 1 means the first month; null when it never is. */
|
|
153
|
+
export function paybackMonths(oneTime: number, savings: number): number | null {
|
|
154
|
+
if (oneTime <= 0) return 1;
|
|
155
|
+
if (savings <= 0) return null;
|
|
156
|
+
return Math.max(1, Math.ceil(oneTime / savings));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Most a month without stored sizes: every size of every image asked for once that month. */
|
|
160
|
+
export function monthlyWithoutMax(sizes: number): number {
|
|
161
|
+
return transformCost(sizes);
|
|
162
|
+
}
|