@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,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored image sizes: the per-image records, and the backfill that makes
|
|
3
|
+
* missing sizes for the whole media library.
|
|
4
|
+
*
|
|
5
|
+
* Records live in plugin storage (collection `imageVariants`, id = media id)
|
|
6
|
+
* and are read and written through the raw D1 binding: page renders read them
|
|
7
|
+
* joined to the media table in one statement (imageInfo in pack.ts), and the
|
|
8
|
+
* writers run after the response (waitUntil), outside any plugin context.
|
|
9
|
+
*
|
|
10
|
+
* The backfill (existing images) only runs when the site turned it on
|
|
11
|
+
* (`imagesVariantsBulk`, Clean Image URLs page): making sizes for a whole
|
|
12
|
+
* library costs Cloudflare transformations, so it's opt-in. It makes widths
|
|
13
|
+
* only; crops are added to a record as pages first use them (addCrops).
|
|
14
|
+
*
|
|
15
|
+
* The backfill's progress is one option row (VARIANTS_STATE_OPTION). Batches
|
|
16
|
+
* are claimed with a compare-and-swap on its `cursor` (the last media id
|
|
17
|
+
* handed out), so the hourly job, the admin's "Make missing sizes now" and an
|
|
18
|
+
* import running at the same time never work on the same images. When no
|
|
19
|
+
* image is left it cleans up (the previous version's files, legacy video
|
|
20
|
+
* posters) and is done; a done run starts over a day later (to retry
|
|
21
|
+
* failures) or at once from the admin.
|
|
22
|
+
*
|
|
23
|
+
* Batches run back to back in a chain of Worker invocations (see
|
|
24
|
+
* src/images/pack.ts, startBackfillChain): one chain at a time, holding a
|
|
25
|
+
* lease on the progress row (`lease`, `leaseUntil`). A chain that dies leaves
|
|
26
|
+
* a lease that runs out LEASE_MS later, and the next trigger takes over.
|
|
27
|
+
* Cloudflare Images refusing work (rate limits, the monthly quota) pauses the
|
|
28
|
+
* run (`pausedUntil`) instead of burning through the library with failures.
|
|
29
|
+
*/
|
|
30
|
+
import { PLUGIN_ID } from "../core/features.js";
|
|
31
|
+
import {
|
|
32
|
+
type ImagesBinding,
|
|
33
|
+
VARIANTS_VERSION,
|
|
34
|
+
VARIANT_MIME_TYPES,
|
|
35
|
+
type VariantBucket,
|
|
36
|
+
type VariantDoc,
|
|
37
|
+
deleteVariants,
|
|
38
|
+
deleteVersion,
|
|
39
|
+
generateVariants,
|
|
40
|
+
ineligibleReason,
|
|
41
|
+
} from "./variants.js";
|
|
42
|
+
|
|
43
|
+
export const VARIANTS_COLLECTION = "imageVariants";
|
|
44
|
+
export const VARIANTS_STATE_OPTION = `plugin:${PLUGIN_ID}:images:variantsState`;
|
|
45
|
+
/** A finished run starts over after this long (picks up failures and anything the hooks missed). */
|
|
46
|
+
export const RESTART_AFTER_MS = 24 * 60 * 60_000;
|
|
47
|
+
/** Images claimed at a time. */
|
|
48
|
+
const BATCH = 5;
|
|
49
|
+
/** Images of a batch made at the same time (each holds its original in memory while its sizes are made). */
|
|
50
|
+
const CONCURRENCY = 2;
|
|
51
|
+
/** How long a chain's lease lasts without being renewed (a step works ~20 s; a dead chain is taken over after this). */
|
|
52
|
+
export const LEASE_MS = 90_000;
|
|
53
|
+
/** How long the run pauses when Cloudflare Images refuses work or a whole batch fails. */
|
|
54
|
+
export const PAUSE_MS = 30 * 60_000;
|
|
55
|
+
|
|
56
|
+
/** The slice of D1 used here (node:sqlite stands in for it in tests). */
|
|
57
|
+
export interface Db {
|
|
58
|
+
prepare(sql: string): {
|
|
59
|
+
bind(...values: unknown[]): {
|
|
60
|
+
run(): Promise<{ meta?: { changes?: number } }>;
|
|
61
|
+
first<T = unknown>(): Promise<T | null>;
|
|
62
|
+
all<T = unknown>(): Promise<{ results: T[] }>;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MIME_LIST = VARIANT_MIME_TYPES.map((m) => `'${m}'`).join(",");
|
|
68
|
+
/** SQL: the media row `m` has no record (`s`) of this version. */
|
|
69
|
+
const INCOMPLETE = `(s.data IS NULL OR json_extract(s.data, '$.v') IS NOT ?1)`;
|
|
70
|
+
const JOIN = `LEFT JOIN _plugin_storage AS s ON s.plugin_id = '${PLUGIN_ID}' AND s.collection = '${VARIANTS_COLLECTION}' AND s.id = m.id`;
|
|
71
|
+
const READY = `(m.status IS NULL OR m.status = 'ready') AND m.mime_type IN (${MIME_LIST})`;
|
|
72
|
+
|
|
73
|
+
// ── Records ──────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export async function writeVariantRecord(db: Db, id: string, doc: VariantDoc): Promise<void> {
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
await db
|
|
78
|
+
.prepare(
|
|
79
|
+
`INSERT INTO _plugin_storage (plugin_id, collection, id, data, revision, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
|
|
80
|
+
ON CONFLICT (plugin_id, collection, id) DO UPDATE SET data = excluded.data, revision = excluded.revision, updated_at = excluded.updated_at`,
|
|
81
|
+
)
|
|
82
|
+
.bind(PLUGIN_ID, VARIANTS_COLLECTION, id, JSON.stringify(doc), crypto.randomUUID(), now)
|
|
83
|
+
.run();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Add crops made on first use to an image's current record. Merged in SQL
|
|
88
|
+
* (the stored list plus the new names, deduplicated), so crops another
|
|
89
|
+
* isolate added meanwhile are kept. No-op when the record is gone, skipped or
|
|
90
|
+
* of another version (the files are then removed with the image, or replaced
|
|
91
|
+
* by the next version's).
|
|
92
|
+
*/
|
|
93
|
+
export async function addCrops(db: Db, id: string, names: string[]): Promise<boolean> {
|
|
94
|
+
if (!names.length) return false;
|
|
95
|
+
const result = await db
|
|
96
|
+
.prepare(
|
|
97
|
+
`UPDATE _plugin_storage SET
|
|
98
|
+
data = json_set(data, '$.c', json((SELECT json_group_array(value) FROM (SELECT value FROM json_each(_plugin_storage.data, '$.c') UNION SELECT value FROM json_each(?1))))),
|
|
99
|
+
revision = ?2, updated_at = ?3
|
|
100
|
+
WHERE plugin_id = ?4 AND collection = ?5 AND id = ?6 AND json_extract(data, '$.v') = ?7 AND json_extract(data, '$.skip') IS NULL`,
|
|
101
|
+
)
|
|
102
|
+
.bind(JSON.stringify(names), crypto.randomUUID(), new Date().toISOString(), PLUGIN_ID, VARIANTS_COLLECTION, id, VARIANTS_VERSION)
|
|
103
|
+
.run();
|
|
104
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function deleteVariantRecord(db: Db, id: string): Promise<void> {
|
|
108
|
+
await db.prepare("DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?").bind(PLUGIN_ID, VARIANTS_COLLECTION, id).run();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface MediaRow {
|
|
112
|
+
id: string;
|
|
113
|
+
storage_key: string;
|
|
114
|
+
mime_type: string | null;
|
|
115
|
+
width: number | null;
|
|
116
|
+
height: number | null;
|
|
117
|
+
size: number | null;
|
|
118
|
+
focal_x: number | null;
|
|
119
|
+
focal_y: number | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface VariantDeps {
|
|
123
|
+
db: Db;
|
|
124
|
+
bucket: VariantBucket;
|
|
125
|
+
images: ImagesBinding;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether an image already has a record of this version with its sizes made (not a skip). */
|
|
129
|
+
export async function hasCurrentVariants(db: Db, id: string): Promise<boolean> {
|
|
130
|
+
const row = await db
|
|
131
|
+
.prepare(
|
|
132
|
+
`SELECT 1 AS ok FROM _plugin_storage WHERE plugin_id = ?1 AND collection = ?2 AND id = ?3 AND json_extract(data, '$.v') = ?4 AND json_extract(data, '$.skip') IS NULL`,
|
|
133
|
+
)
|
|
134
|
+
.bind(PLUGIN_ID, VARIANTS_COLLECTION, id, VARIANTS_VERSION)
|
|
135
|
+
.first<{ ok: number }>();
|
|
136
|
+
return Boolean(row);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Make (or skip) one image's widths and record them (crops come later, on first use). "done", "skipped" (it can't
|
|
141
|
+
* have stored sizes: recorded so it isn't tried again), or "failed" (nothing
|
|
142
|
+
* recorded; tried again on the next run). An image whose sizes of this version
|
|
143
|
+
* were already made is left alone ("done", no transformations) unless `force`:
|
|
144
|
+
* the upload hook, the backfill and a retry can reach the same image.
|
|
145
|
+
*/
|
|
146
|
+
export async function processMedia(
|
|
147
|
+
deps: VariantDeps,
|
|
148
|
+
row: MediaRow,
|
|
149
|
+
onError?: (error: unknown) => void,
|
|
150
|
+
options: { force?: boolean } = {},
|
|
151
|
+
): Promise<"done" | "skipped" | "failed"> {
|
|
152
|
+
if (!options.force && (await hasCurrentVariants(deps.db, row.id))) return "done";
|
|
153
|
+
const reason = ineligibleReason(row.mime_type, row.width, row.size);
|
|
154
|
+
if (reason) {
|
|
155
|
+
await writeVariantRecord(deps.db, row.id, { v: VARIANTS_VERSION, w: [], skip: reason, at: new Date().toISOString() });
|
|
156
|
+
return "skipped";
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const doc = await generateVariants({
|
|
160
|
+
bucket: deps.bucket,
|
|
161
|
+
images: deps.images,
|
|
162
|
+
key: row.storage_key,
|
|
163
|
+
id: row.id,
|
|
164
|
+
width: row.width as number,
|
|
165
|
+
height: row.height,
|
|
166
|
+
focal: row.focal_x != null && row.focal_y != null ? { x: row.focal_x, y: row.focal_y } : null,
|
|
167
|
+
});
|
|
168
|
+
await writeVariantRecord(deps.db, row.id, doc);
|
|
169
|
+
return "done";
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(`coywolf-pack images: couldn't make stored sizes for ${row.storage_key}`, error);
|
|
172
|
+
onError?.(error);
|
|
173
|
+
return "failed";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ROW_COLUMNS = "m.id, m.storage_key, m.mime_type, m.width, m.height, m.size, m.focal_x, m.focal_y";
|
|
178
|
+
|
|
179
|
+
/** One media item's row, if it's a ready image of a type that gets stored sizes. */
|
|
180
|
+
export async function mediaRow(db: Db, id: string): Promise<MediaRow | null> {
|
|
181
|
+
return db.prepare(`SELECT ${ROW_COLUMNS} FROM media AS m WHERE m.id = ?1 AND ${READY}`).bind(id).first<MediaRow>();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Remove a deleted media item's copies (this version and the previous one) and its record. */
|
|
185
|
+
export async function forgetMedia(deps: { db: Db; bucket: VariantBucket }, id: string): Promise<void> {
|
|
186
|
+
await deleteVariants(deps.bucket, [VARIANTS_VERSION, VARIANTS_VERSION - 1], id);
|
|
187
|
+
await deleteVariantRecord(deps.db, id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Backfill ─────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export interface VariantsState {
|
|
193
|
+
version: number;
|
|
194
|
+
/** Last media id handed to a batch. */
|
|
195
|
+
cursor: string;
|
|
196
|
+
done: number;
|
|
197
|
+
skipped: number;
|
|
198
|
+
failed: number;
|
|
199
|
+
phase: "running" | "cleanup" | "done";
|
|
200
|
+
startedAt: string;
|
|
201
|
+
finishedAt?: string;
|
|
202
|
+
error?: string;
|
|
203
|
+
/** The chain working on the run (one at a time): its token, and until when (ISO) it holds the run. Never sent to the admin. */
|
|
204
|
+
lease?: string;
|
|
205
|
+
leaseUntil?: string;
|
|
206
|
+
/** Cloudflare Images refused work (or a whole batch failed): no batches before then (ISO), and why. */
|
|
207
|
+
pausedUntil?: string;
|
|
208
|
+
pauseReason?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function readVariantsState(db: Db): Promise<{ raw: string | null; state: VariantsState | null }> {
|
|
212
|
+
const row = await db.prepare("SELECT value FROM options WHERE name = ?1").bind(VARIANTS_STATE_OPTION).first<{ value: string }>();
|
|
213
|
+
const raw = row?.value ?? null;
|
|
214
|
+
try {
|
|
215
|
+
return { raw, state: raw ? (JSON.parse(raw) as VariantsState) : null };
|
|
216
|
+
} catch {
|
|
217
|
+
return { raw, state: null };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Replace the row only if nobody changed it since `before` was read (null: only if it doesn't exist). */
|
|
222
|
+
async function swap(db: Db, before: string | null, after: VariantsState): Promise<boolean> {
|
|
223
|
+
const value = JSON.stringify(after);
|
|
224
|
+
const result =
|
|
225
|
+
before === null
|
|
226
|
+
? await db.prepare("INSERT INTO options (name, value) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING").bind(VARIANTS_STATE_OPTION, value).run()
|
|
227
|
+
: await db.prepare("UPDATE options SET value = ?1 WHERE name = ?2 AND value = ?3").bind(value, VARIANTS_STATE_OPTION, before).run();
|
|
228
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Apply `update` to the current row (compare-and-swap, retried). */
|
|
232
|
+
async function updateState(db: Db, update: (state: VariantsState) => VariantsState | null): Promise<VariantsState | null> {
|
|
233
|
+
for (let i = 0; i < 10; i++) {
|
|
234
|
+
const { raw, state } = await readVariantsState(db);
|
|
235
|
+
if (!state) return null;
|
|
236
|
+
const next = update(state);
|
|
237
|
+
if (!next) return state;
|
|
238
|
+
if (await swap(db, raw, next)) return next;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const freshState = (now: number): VariantsState => ({
|
|
244
|
+
version: VARIANTS_VERSION,
|
|
245
|
+
cursor: "",
|
|
246
|
+
done: 0,
|
|
247
|
+
skipped: 0,
|
|
248
|
+
failed: 0,
|
|
249
|
+
phase: "running",
|
|
250
|
+
startedAt: new Date(now).toISOString(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/** Start a new run unless one is under way (or, without `force`, one finished less than a day ago). */
|
|
254
|
+
export async function startVariantsRun(db: Db, options: { force?: boolean; now?: number } = {}): Promise<VariantsState> {
|
|
255
|
+
const now = options.now ?? Date.now();
|
|
256
|
+
for (let i = 0; i < 10; i++) {
|
|
257
|
+
const { raw, state } = await readVariantsState(db);
|
|
258
|
+
const stale = !state || state.version !== VARIANTS_VERSION;
|
|
259
|
+
const restart =
|
|
260
|
+
stale ||
|
|
261
|
+
(state.phase === "done" && (options.force || !state.finishedAt || now - Date.parse(state.finishedAt) >= RESTART_AFTER_MS));
|
|
262
|
+
if (!restart) return state as VariantsState;
|
|
263
|
+
const next = freshState(now);
|
|
264
|
+
if (await swap(db, raw, next)) return next;
|
|
265
|
+
}
|
|
266
|
+
return (await readVariantsState(db)).state ?? freshState(now);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Claim the next batch of images missing their sizes; null when another isolate moved the cursor first. */
|
|
270
|
+
async function claim(db: Db): Promise<{ state: VariantsState; rows: MediaRow[]; from: string } | null> {
|
|
271
|
+
const { raw, state } = await readVariantsState(db);
|
|
272
|
+
if (!state || state.phase !== "running") return { state: state as VariantsState, rows: [], from: state?.cursor ?? "" };
|
|
273
|
+
const { results } = await db
|
|
274
|
+
.prepare(`SELECT ${ROW_COLUMNS} FROM media AS m ${JOIN} WHERE m.id > ?2 AND ${READY} AND ${INCOMPLETE} ORDER BY m.id LIMIT ${BATCH}`)
|
|
275
|
+
.bind(VARIANTS_VERSION, state.cursor)
|
|
276
|
+
.all<MediaRow>();
|
|
277
|
+
const next: VariantsState = results.length ? { ...state, cursor: results[results.length - 1].id } : { ...state, phase: "cleanup" };
|
|
278
|
+
return (await swap(db, raw, next)) ? { state: next, rows: results, from: state.cursor } : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface BackfillDeps extends VariantDeps {
|
|
282
|
+
/** Copy legacy video posters' sizes during cleanup; true when none are left. */
|
|
283
|
+
upgradePosters?: (deadline: number) => Promise<boolean>;
|
|
284
|
+
/**
|
|
285
|
+
* Asked before each batch and before the cleanup: false stops the run where
|
|
286
|
+
* it is (sizes for existing images were turned off meanwhile). The state
|
|
287
|
+
* keeps its cursor; turning the setting back on starts over anyway.
|
|
288
|
+
*/
|
|
289
|
+
keepGoing?: () => Promise<boolean>;
|
|
290
|
+
/**
|
|
291
|
+
* Called once per run, by the step that finished it, when the run made sizes
|
|
292
|
+
* for at least one image: pages cached with /s/ can now use them (the pack
|
|
293
|
+
* clears the page cache and warms it again).
|
|
294
|
+
*/
|
|
295
|
+
onDone?: (state: VariantsState) => Promise<void>;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** An error from Cloudflare Images that means "not now": rate limiting, or the transformation quota. */
|
|
299
|
+
export function isThrottled(error: unknown): boolean {
|
|
300
|
+
const text = error instanceof Error ? `${error.message} ${(error as { code?: unknown }).code ?? ""}` : String(error);
|
|
301
|
+
return /\b429\b|rate.?limit|too many|quota|exceeded/i.test(text);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Run `work` over `items`, `limit` at a time. */
|
|
305
|
+
async function pool<T>(items: T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {
|
|
306
|
+
let next = 0;
|
|
307
|
+
const runner = async () => {
|
|
308
|
+
while (next < items.length) await work(items[next++]);
|
|
309
|
+
};
|
|
310
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Whether a run is paused at `now`. */
|
|
314
|
+
export const pausedAt = (state: Pick<VariantsState, "pausedUntil">, now: number) => Boolean(state.pausedUntil && Date.parse(state.pausedUntil) > now);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Work on the current run until `budgetMs` has passed (no batch is claimed
|
|
318
|
+
* after that) or `maxImages` images were handled: claim a batch, make its
|
|
319
|
+
* sizes, record the counts; when none are left, clean up and finish. A batch
|
|
320
|
+
* that Cloudflare Images refused (rate limit, quota) is handed back (the
|
|
321
|
+
* cursor moves back) and the run pauses for PAUSE_MS; a batch that failed
|
|
322
|
+
* entirely for another reason is counted as failed (retried on the next run)
|
|
323
|
+
* and pauses it too. Returns the state at the end.
|
|
324
|
+
*/
|
|
325
|
+
export async function backfillStep(deps: BackfillDeps, options: { budgetMs: number; maxImages?: number; now?: () => number }): Promise<VariantsState | null> {
|
|
326
|
+
const clock = options.now ?? Date.now;
|
|
327
|
+
const deadline = clock() + options.budgetMs;
|
|
328
|
+
let handled = 0;
|
|
329
|
+
let state = await startVariantsRun(deps.db, { now: clock() });
|
|
330
|
+
while (clock() < deadline && handled < (options.maxImages ?? Number.POSITIVE_INFINITY)) {
|
|
331
|
+
if (state.phase === "done" || pausedAt(state, clock())) break;
|
|
332
|
+
// Each batch and the cleanup cost transformations: stop as soon as the site turned them off.
|
|
333
|
+
if (deps.keepGoing && !(await deps.keepGoing())) break;
|
|
334
|
+
if (state.phase === "cleanup") {
|
|
335
|
+
const left = deadline - clock();
|
|
336
|
+
const versionsDone = await deleteVersion(deps.bucket, VARIANTS_VERSION - 1, left).catch(() => false);
|
|
337
|
+
const postersDone = deps.upgradePosters ? await deps.upgradePosters(deadline).catch(() => false) : true;
|
|
338
|
+
if (!versionsDone || !postersDone) break;
|
|
339
|
+
// Exactly one step finishes the run (compare-and-swap); only that one calls onDone.
|
|
340
|
+
const finishing: { state?: VariantsState } = {};
|
|
341
|
+
const after = await updateState(deps.db, (s) =>
|
|
342
|
+
s.phase === "cleanup" ? (finishing.state = { ...s, phase: "done", finishedAt: new Date(clock()).toISOString(), lease: undefined, leaseUntil: undefined }) : null,
|
|
343
|
+
);
|
|
344
|
+
state = after ?? state;
|
|
345
|
+
if (after && after === finishing.state && state.done > 0 && deps.onDone) {
|
|
346
|
+
await deps.onDone(state).catch((error) => console.error("coywolf-pack images: after the run finished", error));
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
const claimed = await claim(deps.db);
|
|
351
|
+
if (!claimed) {
|
|
352
|
+
state = (await readVariantsState(deps.db)).state ?? state;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
state = claimed.state;
|
|
356
|
+
if (!claimed.rows.length) continue;
|
|
357
|
+
const counts = { done: 0, skipped: 0, failed: 0 };
|
|
358
|
+
let throttled = false;
|
|
359
|
+
await pool(claimed.rows, CONCURRENCY, async (row) => {
|
|
360
|
+
counts[await processMedia(deps, row, (error) => (throttled ||= isThrottled(error)))]++;
|
|
361
|
+
});
|
|
362
|
+
handled += claimed.rows.length;
|
|
363
|
+
const allFailed = counts.failed === claimed.rows.length && claimed.rows.length >= 3;
|
|
364
|
+
const pause = throttled || allFailed ? new Date(clock() + PAUSE_MS).toISOString() : undefined;
|
|
365
|
+
state =
|
|
366
|
+
(await updateState(deps.db, (s) => ({
|
|
367
|
+
...s,
|
|
368
|
+
done: s.done + counts.done,
|
|
369
|
+
skipped: s.skipped + counts.skipped,
|
|
370
|
+
// Refused by Cloudflare Images: not failures; the batch's images are handed back for after the pause.
|
|
371
|
+
failed: s.failed + (throttled ? 0 : counts.failed),
|
|
372
|
+
...(throttled && s.cursor === claimed.state.cursor ? { cursor: claimed.from } : {}),
|
|
373
|
+
...(pause ? { pausedUntil: pause, pauseReason: throttled ? "Cloudflare Images is limiting transformations" : "every image of a batch failed" } : {}),
|
|
374
|
+
}))) ?? state;
|
|
375
|
+
}
|
|
376
|
+
return state;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── The chain's lease ────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Whether a progress row (raw, as last read with the feature switches) may
|
|
383
|
+
* have a batch for a new chain: a run under way or cleaning up, not paused,
|
|
384
|
+
* and no chain holding it. False when unknown (no read yet) or unreadable, so
|
|
385
|
+
* page requests never add a query to find out.
|
|
386
|
+
*/
|
|
387
|
+
export function backfillMayHaveWork(raw: string | null | undefined, now = Date.now()): boolean {
|
|
388
|
+
if (!raw) return false;
|
|
389
|
+
try {
|
|
390
|
+
const state = JSON.parse(raw) as VariantsState;
|
|
391
|
+
if (state.version !== VARIANTS_VERSION || (state.phase !== "running" && state.phase !== "cleanup") || pausedAt(state, now)) return false;
|
|
392
|
+
return !(state.lease && state.leaseUntil && Date.parse(state.leaseUntil) > now);
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Take (or renew) the run for the chain `token`, for LEASE_MS. `follow`: a
|
|
400
|
+
* chain's next step, which only goes on while the run is still its own (a
|
|
401
|
+
* request with any other token gets nothing). Without it, the run is taken
|
|
402
|
+
* when no other chain holds it (none, or its lease ran out). Null when the
|
|
403
|
+
* run isn't there to work on (none, done, paused) or is someone else's.
|
|
404
|
+
*/
|
|
405
|
+
export async function acquireLease(db: Db, token: string, options: { follow?: boolean; now?: number } = {}): Promise<VariantsState | null> {
|
|
406
|
+
const now = options.now ?? Date.now();
|
|
407
|
+
for (let i = 0; i < 5; i++) {
|
|
408
|
+
const { raw, state } = await readVariantsState(db);
|
|
409
|
+
if (!raw || !state || state.version !== VARIANTS_VERSION || (state.phase !== "running" && state.phase !== "cleanup") || pausedAt(state, now)) return null;
|
|
410
|
+
const mine = state.lease === token;
|
|
411
|
+
const free = !state.lease || !state.leaseUntil || Date.parse(state.leaseUntil) <= now;
|
|
412
|
+
if (options.follow ? !mine : !mine && !free) return null;
|
|
413
|
+
const next = { ...state, lease: token, leaseUntil: new Date(now + LEASE_MS).toISOString() };
|
|
414
|
+
if (await swap(db, raw, next)) return next;
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Let go of the run (when the chain stops), so the next trigger can start a new chain at once. */
|
|
420
|
+
export async function releaseLease(db: Db, token: string): Promise<VariantsState | null> {
|
|
421
|
+
return updateState(db, (s) => (s.lease === token ? { ...s, lease: undefined, leaseUntil: undefined } : null));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The state without the lease token (for the admin). */
|
|
425
|
+
export function publicState(state: VariantsState | null): Omit<VariantsState, "lease"> | null {
|
|
426
|
+
if (!state) return null;
|
|
427
|
+
const { lease: _lease, ...rest } = state;
|
|
428
|
+
return rest;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Library counts for the admin: images that can have stored sizes, how many have them, how many are skipped. */
|
|
432
|
+
export async function variantCounts(db: Db): Promise<{ total: number; stored: number; skipped: number }> {
|
|
433
|
+
const row = await db
|
|
434
|
+
.prepare(
|
|
435
|
+
`SELECT COUNT(*) AS total,
|
|
436
|
+
SUM(CASE WHEN NOT ${INCOMPLETE} AND json_extract(s.data, '$.skip') IS NULL THEN 1 ELSE 0 END) AS stored,
|
|
437
|
+
SUM(CASE WHEN NOT ${INCOMPLETE} AND json_extract(s.data, '$.skip') IS NOT NULL THEN 1 ELSE 0 END) AS skipped
|
|
438
|
+
FROM media AS m ${JOIN} WHERE ${READY}`,
|
|
439
|
+
)
|
|
440
|
+
.bind(VARIANTS_VERSION)
|
|
441
|
+
.first<{ total: number; stored: number | null; skipped: number | null }>();
|
|
442
|
+
return { total: row?.total ?? 0, stored: row?.stored ?? 0, skipped: row?.skipped ?? 0 };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** One media-library image for the cost estimate; `current` is 1 when it already has a record of this version. */
|
|
446
|
+
export interface EstimateRow {
|
|
447
|
+
mime_type: string | null;
|
|
448
|
+
width: number | null;
|
|
449
|
+
height: number | null;
|
|
450
|
+
size: number | null;
|
|
451
|
+
current: number;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Every ready image of a type that can get stored sizes (admin only: one scan of the media table). */
|
|
455
|
+
export async function estimateRows(db: Db): Promise<EstimateRow[]> {
|
|
456
|
+
const { results } = await db
|
|
457
|
+
.prepare(`SELECT m.mime_type, m.width, m.height, m.size, CASE WHEN ${INCOMPLETE} THEN 0 ELSE 1 END AS current FROM media AS m ${JOIN} WHERE ${READY}`)
|
|
458
|
+
.bind(VARIANTS_VERSION)
|
|
459
|
+
.all<EstimateRow>();
|
|
460
|
+
return results;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Published entries of the site's routable collections (its pages, for the traffic estimate); null when it can't be counted. */
|
|
464
|
+
export async function publishedCount(db: Db): Promise<number | null> {
|
|
465
|
+
try {
|
|
466
|
+
const { results } = await db.prepare("SELECT slug FROM _emdash_collections WHERE routable = 1").bind().all<{ slug: string }>();
|
|
467
|
+
const tables = results.map((r) => r.slug).filter((slug) => /^[a-z0-9_]+$/.test(slug));
|
|
468
|
+
if (!tables.length) return 0;
|
|
469
|
+
const sum = tables.map((slug) => `(SELECT COUNT(*) FROM "ec_${slug}" WHERE status = 'published' AND deleted_at IS NULL)`).join(" + ");
|
|
470
|
+
const row = await db.prepare(`SELECT ${sum} AS n`).bind().first<{ n: number }>();
|
|
471
|
+
return row?.n ?? 0;
|
|
472
|
+
} catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|