@coywolf/emdash 0.35.13 → 0.35.15

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.
Files changed (55) hide show
  1. package/README.md +14 -4
  2. package/docs/README.md +45 -0
  3. package/docs/ai.md +39 -0
  4. package/docs/backups.md +88 -0
  5. package/docs/breadcrumbs.md +59 -0
  6. package/docs/code-blocks.md +21 -0
  7. package/docs/content-urls.md +41 -0
  8. package/docs/custom-blocks.md +27 -0
  9. package/docs/discovery.md +32 -0
  10. package/docs/files.md +44 -0
  11. package/docs/form-uploads.md +59 -0
  12. package/docs/headings.md +37 -0
  13. package/docs/images.md +141 -0
  14. package/docs/internals.md +13 -0
  15. package/docs/links.md +34 -0
  16. package/docs/performance.md +72 -0
  17. package/docs/redirects.md +84 -0
  18. package/docs/releasing.md +21 -0
  19. package/docs/reviews.md +89 -0
  20. package/docs/robots.md +81 -0
  21. package/docs/schema.md +79 -0
  22. package/docs/search.md +114 -0
  23. package/docs/videos.md +48 -0
  24. package/docs/wordpress-import.md +198 -0
  25. package/package.json +11 -2
  26. package/src/admin/features.tsx +4 -0
  27. package/src/admin/index.tsx +7 -1
  28. package/src/admin/update-notice.tsx +198 -0
  29. package/src/core/features.ts +10 -0
  30. package/src/core/module.ts +2 -0
  31. package/src/index.ts +11 -3
  32. package/src/modules.ts +3 -0
  33. package/src/options.ts +2 -0
  34. package/src/pageCache/lib.ts +4 -2
  35. package/src/updates/check.ts +135 -0
  36. package/src/updates/core.ts +56 -0
  37. package/src/updates/pack.ts +54 -0
  38. package/src/version.ts +1 -1
  39. package/src/ai/logic.test.ts +0 -187
  40. package/src/ai/queue.test.ts +0 -97
  41. package/src/ai/wikidata.test.ts +0 -184
  42. package/src/backups/dump.test.mjs +0 -287
  43. package/src/backups/restore.test.mjs +0 -103
  44. package/src/codeBlocks/render.test.ts +0 -152
  45. package/src/files/counts.test.ts +0 -114
  46. package/src/files/format.test.ts +0 -93
  47. package/src/files/r2-rules.test.ts +0 -30
  48. package/src/files/sigv4.test.ts +0 -64
  49. package/src/headings/headings.test.ts +0 -159
  50. package/src/pageCache/lib.test.mjs +0 -224
  51. package/src/pageCache/warm.test.mjs +0 -670
  52. package/src/redirects/slug-core.test.ts +0 -50
  53. package/src/robots/rep.test.ts +0 -335
  54. package/src/robots/rules.test.ts +0 -251
  55. package/src/schema/graph.test.mjs +0 -522
package/docs/images.md ADDED
@@ -0,0 +1,141 @@
1
+ # Clean Image URLs
2
+
3
+ [Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
4
+
5
+ Resized copies of media-library images at short, cacheable addresses instead of Astro's `/_image?href=…&w=…&h=…` endpoint. Off until you turn on **Clean image URLs** on the Coywolf Pack page; the **Clean Image URLs** admin page appears once it's on.
6
+
7
+ ## Two modes
8
+
9
+ **Media host (recommended).** A subdomain of the site, such as `media.coywolf.com`, serves the media bucket directly from Cloudflare, which resizes on the fly and picks AVIF or WebP for browsers that accept them. Images never touch the site's Worker.
10
+
11
+ ```
12
+ https://media.coywolf.com/<file> the original file
13
+ https://media.coywolf.com/s/<w>x<h>/<file> cropped to fill (fit: cover)
14
+ https://media.coywolf.com/s/<w>/<file> width only, keeps the ratio
15
+ ```
16
+
17
+ `<file>` is the media item's stored file name (`<id>.<ext>`, as in `/_emdash/api/media/file/<id>.<ext>`). Sizes go up to 2560px.
18
+
19
+ **Worker route (no setup).** Without a media host, the pack's middleware serves
20
+
21
+ ```
22
+ /media/<file id>-<width>x<height>.<webp|avif|jpg|png> cropped to fill (fit: cover)
23
+ /media/<file id>-<width>w.<format> width only, keeps the ratio
24
+ ```
25
+
26
+ reading the original from the media bucket (`MEDIA`), resizing it with the Cloudflare Images binding (`IMAGES`, which the Astro Cloudflare adapter already binds) and caching the result at the edge for a year (keyed by the path alone, so a query string never forces a new resize). Widths and heights on this route are multiples of 10px: `cleanImageUrl()` rounds other sizes up (640×336 becomes 640×340), and a request for an off-grid size gets a 301 to the rounded one, so nobody can make the Worker run (and bill) a new resize for every pixel. SVGs aren't resized. Other binding names: `coywolfPlugin({ images: { bucket: "MYMEDIA", images: "MYIMAGES" } })`. Once a media host is set, these addresses redirect (301) to the same size on the media host, so old links keep working.
27
+
28
+ ## Setting up a media host
29
+
30
+ The media host needs three things on Cloudflare, in the zone of the site's domain, and works best with a fourth:
31
+
32
+ 1. **Image Transformations** turned on for the zone (Images → Transformations → Enable for zone; same-zone sources only, so leave "Resize images from any origin" off).
33
+ 2. **An R2 custom domain** on the media bucket (the bucket bound as `MEDIA`): R2 → the bucket → Settings → Custom Domains → Add `media.coywolf.com`, minimum TLS 1.2. Cloudflare adds the DNS record and certificate.
34
+ 3. **Two URL rewrite rules** (Rules → URL Rewrite, phase `http_request_transform`), named exactly so the setup button recognizes them:
35
+
36
+ | Name | Filter expression | Path rewrite (dynamic) |
37
+ | --- | --- | --- |
38
+ | `media.coywolf.com: /s/<W>x<H>/<file> → cropped resize (Coywolf Pack clean image URLs)` | `(http.host eq "media.coywolf.com" and http.request.uri.path wildcard "/s/*x*/*")` | `wildcard_replace(http.request.uri.path, "/s/*x*/*", "/cdn-cgi/image/width=${1},height=${2},fit=cover,format=auto,quality=85/${3}")` |
39
+ | `media.coywolf.com: /s/<W>/<file> → resize to width (Coywolf Pack clean image URLs)` | `(http.host eq "media.coywolf.com" and http.request.uri.path wildcard "/s/*/*" and not http.request.uri.path wildcard "/s/*x*/*")` | `wildcard_replace(http.request.uri.path, "/s/*/*", "/cdn-cgi/image/width=${1},format=auto,quality=85/${2}")` |
40
+
41
+ 4. **Smart Tiered Cache** turned on for the zone (Caching → Tiered Cache → Smart Tiered Caching; free on every plan). Each Cloudflare data center has its own cache, so an image nobody has loaded there yet goes back to R2. With Smart Tiered Cache, that data center asks a nearby upper-tier Cloudflare cache first (placed near the R2 bucket), so after one visit anywhere, first loads elsewhere come from Cloudflare's cache. Pages don't need it: Workers Cache is always tiered. The setup only ever turns it on, never off.
42
+
43
+ Do it by hand with the step-by-step guide on the Clean Image URLs page, or let **Set up media host** on that page do it through the Cloudflare API: enter the media host, your Cloudflare account ID, the media bucket's name and an API token, select **Review setup** to see the plan ("Turn on Image Transformations for coywolf.com, connect media.coywolf.com to the R2 bucket example-media, add 2 URL rewrite rules, turn on Smart Tiered Cache"), then **Apply**. Anything already done is skipped (a token without permission for Smart Tiered Cache still gets the other steps, and that step names the missing permission), the zone's other URL rewrite rules are kept as they are, and only the pack's own two rules (matched by name) are replaced. It finishes with a check. The page checks the media host each time it opens: when it works, Set up media host shows as done with **Run setup again**, and the host field opens with **Change host**.
44
+
45
+ With a media host set, every page the site serves carries `Link: <https://media.coywolf.com>; rel=preconnect`, so browsers open the connection to the media host (DNS, TCP and TLS) while the page is still arriving, not when they reach the first image.
46
+
47
+ The API token needs these permissions (Account Resources: your account; Zone Resources: the site's zone):
48
+
49
+ - Zone → Zone → Read (to find the zone)
50
+ - Zone → Zone Settings → Edit (Image Transformations and Smart Tiered Cache)
51
+ - Zone → Transform Rules → Edit, and Account → Account Rulesets → Read (the URL rewrite rules)
52
+ - Account → Workers R2 Storage → Edit, and Zone → DNS → Edit (the custom domain and its DNS record)
53
+
54
+ The token can be typed just for the setup, saved (encrypted) on the page or the plugin's Settings page, or set as the `IMAGES_API_TOKEN` (or `CLOUDFLARE_API_TOKEN`) Worker secret; the account ID can come from `CF_ACCOUNT_ID`. It's only sent to `api.cloudflare.com`, and you can delete it in Cloudflare afterward.
55
+
56
+ **Check** (on the page) fetches a recent image from the media host, and a 64×64 copy of it twice, and reports whether the host is reachable, the original is served, resizing works (`cf-resized` header or an image type) and the copy is cached.
57
+
58
+ Then turn the media host on, either on the Clean Image URLs page (takes effect within a minute, no deploy) or in `astro.config.mjs`:
59
+
60
+ ```js
61
+ coywolfPlugin({ images: { cdn: "https://media.coywolf.com" } })
62
+ ```
63
+
64
+ A host saved on the page wins over the option; clear it there to fall back to the option. The host must be an `https://` origin with no path.
65
+
66
+ **Quota.** Cloudflare's Free plan includes 5,000 unique image transformations a month (each new size of each image counts once a month; repeat views come from the cache). Beyond that, transformations need a paid Cloudflare Images plan.
67
+
68
+ ## Open Graph and schema
69
+
70
+ With Schema & Social's Open Graph extras on:
71
+
72
+ - Pages without their own image use the site's default OG image (Settings → SEO) at a clean 1200×630 URL with its width, height, type and alt. On the media host that's `https://media.coywolf.com/s/1200x630/<file>` (crawlers that don't ask for WebP get the original format, so the type is the original's); on the Worker route PNG stays PNG and anything else becomes JPEG.
73
+ - With a media host, a page's own image (featured image or SEO image from the media library) is output as its original on the media host, with its width, height, type and alt. The JSON-LD `primaryImageOfPage` uses the same URL.
74
+ - These og:image and twitter:image tags replace EmDash's own (never a second og:image), and clean URLs anywhere in og:image resolve back to their media item.
75
+
76
+ ## In theme code
77
+
78
+ ```astro
79
+ ---
80
+ import { cleanImageUrl, originalImageUrl } from "@coywolf/emdash/astro";
81
+ const src = post.data.featured_image?.src;
82
+ const thumb = await cleanImageUrl(src, { width: 600, height: 315 });
83
+ const full = await originalImageUrl(src);
84
+ ---
85
+ {thumb && <img src={thumb} width="600" height="315" alt="" />}
86
+ <a href={full}>Full size</a>
87
+ ```
88
+
89
+ - `cleanImageUrl(src, { width, height?, format? })`: the resized URL (on the media host when set, else `/media/…`; `format` applies to the Worker route only, as the media host picks it). `null` when the feature is off, `src` isn't a media-library file, or it's an AVIF original on a media host (Cloudflare's resizer can't read AVIF), so fall back to your usual image code.
90
+ - `originalImageUrl(src)` (also exported as `mediaUrl`): the original file on the media host; returns `src` unchanged when the feature is off, no media host is set, or `src` isn't a media-library file.
91
+ - `imageDimensions(srcs)`: a `Map` of `src` → `{ width, height }` from the media library, for images whose Portable Text block has no size (WordPress imports don't record one). One query for a whole page, remembered per isolate; works whether or not the feature is on. Use it to build `srcset` and set `width`/`height` on content images.
92
+
93
+ ## Stored image sizes
94
+
95
+ With a media host, each image is also stored, once, in a few sizes as AVIF and WebP, next to the original in the media bucket, and pages use those files instead of resizing on each visit:
96
+
97
+ ```
98
+ https://media.coywolf.com/v1/<id>-<w>.avif|.webp widths 400, 640, 800, 1200, 1600 (keeps the ratio)
99
+ https://media.coywolf.com/v1/<id>-<w>x<h>.avif|.webp the site's crops (fit: cover, around the image's focal point)
100
+ ```
101
+
102
+ - **What's made.** Each width smaller than the original (the original is always the largest candidate; images under 400 pixels have none and render as a plain `<img>`), and, when a page first shows it, each crop in `images.crops` that fits inside the original (never upscaled). JPEG, PNG and WebP originals up to 20 MB; GIFs, SVGs, AVIF originals and larger files keep using `/s/`. WebP is made at quality 85, AVIF at 80; Cloudflare doesn't encode AVIF beyond 1200 pixels on a side, so those sizes are stored as WebP under the `.avif` name too (with their real `Content-Type: image/webp`; a `<source type="image/avif">` only picks the candidate list, and every browser that takes AVIF also decodes WebP). Files are stored with `Cache-Control: public, max-age=31536000, immutable`.
103
+ - **Crops** are the exact sizes the theme shows (list thumbnails, avatars at 1x and 2x, the Testimonial block's 64×64 and 128×128 photo). They're made **on first use**: when `croppedImage()` finds an image's widths stored but a listed crop missing, it makes just that crop after the response (one at a time per isolate, at most 6 images queued; a failure waits an hour before it's tried again), merges it into the record in SQL (crops added meanwhile by another isolate are kept), and marks the page as a stopgap so its next render uses it. So avatars and testimonial crops are made only for the images that are shown that way, and list crops only for featured images. A size not in `images.crops` always uses `/s/`.
104
+
105
+ ```js
106
+ coywolfPlugin({ images: { cdn: "https://media.coywolf.com", crops: [[400, 210], [600, 315], [900, 473], [50, 50], [100, 100], [64, 64], [128, 128]] } })
107
+ ```
108
+
109
+ Adding crops later is fine: they're made as pages show them. Removed crops' files stay until the image is deleted.
110
+ - **When.** New images: right after an upload (in the background; the upload doesn't wait; EmDash 1.2.0+ notifies plugins of direct uploads too, once confirmed), and after a file is replaced. An image whose sizes already exist isn't made again. These always run (one image's widths cost a few cents at most).
111
+ - **Existing images are opt-in.** Making sizes for a whole library (for example after a WordPress import) is a one-time Cloudflare fee, so it doesn't start on its own. Until an admin turns on **Sizes for existing images** on the Clean Image URLs page (setting `imagesVariantsBulk`, read with the feature switches, so checking it adds no query), the backfill, the step after a WordPress media import and legacy video-poster upgrades do nothing, and existing images keep using `/s/`. Turning it on takes a confirmation that shows the estimate (below) and starts a run. A finished run starts over a day later to retry failures. Turning it off stops further work, including a run under way (it checks the setting before each batch of five images and before the cleanup, so within the switches' cache); sizes already made stay in use. Turning it back on starts a fresh run.
112
+ - **Back-to-back batches.** A run works in a chain of steps, each in a Worker invocation of its own (so each gets its own subrequest budget), one after another until every image is done; nobody has to keep the admin page open. Turning sizes for existing images on, **Make missing sizes now**, a WordPress media import, the hourly job (it restarts a run that finished a day ago, or a chain that stopped), and page traffic (a request that sees a run with no chain working on it, from the progress row read with the feature switches, so no extra query) start a chain. The trigger takes the run's **lease** (a compare-and-swap on the progress row `plugin:coywolf-pack:images:variantsState`: one chain at a time) and posts the first step to the Worker itself through the `SELF` service binding (internal path `/_coywolf-pack/images/backfill`), so no image work happens in the Cron Trigger, which runs wherever Cloudflare has room, often far from the database (the same reason cache warming rides on traffic). Each step renews the lease, makes sizes for up to 25 images (2 at a time; it claims no new batch after 15 seconds), then posts the next step. The endpoint only works for the request carrying the lease's current token (a random UUID kept in the database, never sent to browsers); anything else gets a 403 and starts nothing, so visitors can't trigger transformations. Budget per step: an image takes up to ~22 subrequests (a list, the original, and for up to five widths a WebP and an AVIF transform and two writes) plus its record, so 25 images are at most ~575, plus ~30 for claims, counts, the lease and the next step: about 600 of the 1,000 an invocation may make, finishing within the 30 seconds a step's background work may run after its response. That's roughly 2,000–4,000 images an hour (fewer with large originals), instead of 15 an hour from the hourly job alone. Cloudflare allows 32 Worker invocations per incoming request, so a chain lets go after 24 steps and the next page request (or the hourly job) starts a new one; a chain that dies leaves a lease that runs out after 90 seconds and is taken over the same way. When Cloudflare Images rate-limits or refuses work (a 429, the monthly quota), the batch is handed back and the run pauses for 30 minutes (`pausedUntil`; a batch where every image failed for another reason pauses it too, its images counted as failed); page traffic and the hourly job resume it afterward. The page shows the progress ("working, about N images an hour", or paused until when). Without the `SELF` binding, or when the hand-off isn't accepted (something ahead of the pack's middleware refuses the internal request, as a staging site's password gate does), each trigger makes one step's worth of sizes itself and lets go; the hourly job and page traffic carry on from there.
113
+ - **Until an image's sizes exist** (and for images that can't have them), `responsiveImage()` and `croppedImage()` return `null` and themes use `cleanImageUrl()` (`/s/`). A page that showed such a stopgap is cached for minutes instead of days, so its next render uses the stored files, only when the sizes are moments away: the image was uploaded in the last 15 minutes (`created_at` comes with the media row `imageInfo()` already reads), or a crop was just queued. Existing images waiting for the backfill don't shorten pages (that would rebuild every page every few minutes for as long as the run takes): those pages keep their normal lifetime, and when a run that made sizes for at least one image finishes, the backfill clears the page cache once (exactly once per run: only the step whose compare-and-swap finishes the run does it) and, with cache warming on, starts a warm-up, so every page picks up the stored sizes together. An image that can't have sizes, or a crop that failed recently, uses `/s/` and the page is cached normally.
114
+ - **Records.** Each image's sizes are recorded in plugin storage (`imageVariants`, by media id) once all its files are in the bucket; pages read them with the media rows in one query per page (`imageInfo()`), remembered per isolate for up to 10 minutes.
115
+ - **Deleting** an image from the media library deletes its stored sizes and record. A replaced file gets new sizes (the media host may keep serving cached old ones, as it does for the original).
116
+ - **Changing the widths or quality** (in `src/images/variants.ts`) means bumping `VARIANTS_VERSION`: renders treat older records as missing, the backfill makes the new set (with sizes for existing images on; otherwise those images use `/s/`), and its cleanup deletes the previous version's files.
117
+ - **Cost.** Each stored file is one transformation, made once (Free plan: 5,000 a month; beyond that $0.50 per 1,000 on a paid Images plan): a size is two (WebP and AVIF), or one above 1,200 pixels. An image with 4 widths is about 7, so a 2,000-image library is about 14,000 once. After that, visits cost nothing but R2 storage and reads. Without stored sizes, `/s/` costs one transformation per size of each image asked for, **every month** (the AVIF and WebP a visitor gets from one `/s/` URL count once).
118
+ - **Estimate.** The Clean Image URLs page shows, from one scan of the media table and a count of published entries (admin only, `images/variants/estimate`): the one-time fee as a range (the site may already have used the month's free 5,000), storage a month (rough per-width file sizes; R2 at $0.015 per GB-month), the most `/s/` could cost a month (every size of every image asked for once) and when the fee pays for itself, and a **traffic calculator** (page views a month; pages seen = P × (1 − e^(−V/P)), at least 80% with search engines crawling; about 3 sizes per image shown; one Open Graph `/s/` image per page either way). The math is in `src/images/variants-estimate.ts`.
119
+ - **Video posters** get stored sizes too (400, 800 and 1200 wide); posters copied before 0.30.0 keep using `/s/` until the backfill's cleanup makes theirs (only with sizes for existing images on).
120
+ - **Open Graph** images stay on `/s/1200x630/` (crawlers that don't ask for WebP get the original's format there).
121
+
122
+ In theme code, read every image of the page once, then build each `<picture>`:
123
+
124
+ ```astro
125
+ ---
126
+ import { croppedImage, imageInfo, responsiveImage, cleanImageUrl } from "@coywolf/emdash/astro";
127
+ await imageInfo(allImageSrcsOnThePage); // one query; later calls come from the cache
128
+ const r = await responsiveImage(src, { locals: Astro.locals });
129
+ const thumb = await croppedImage(src, [[400, 210, "400w"], [600, 315, "600w"], [900, 473, "900w"]], { locals: Astro.locals, srcIndex: 1 });
130
+ ---
131
+ {r ? (
132
+ <picture>
133
+ <source type="image/avif" srcset={r.avif} sizes="(min-width: 50rem) 768px, 100vw" />
134
+ <img src={r.src} srcset={r.webp} sizes="(min-width: 50rem) 768px, 100vw" width={r.width} height={r.height} alt="" />
135
+ </picture>
136
+ ) : (
137
+ <img src={await cleanImageUrl(src, { width: 800 })} alt="" />
138
+ )}
139
+ ```
140
+
141
+ Or use the `ResponsiveImage` component (`src`, `alt`, `sizes`, `width`, `height`, `priority`, `class`, `dataFull`), which renders the `<picture>` or the `/s/` fallback.
@@ -0,0 +1,13 @@
1
+ # Internals
2
+
3
+ [Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
4
+
5
+ ## Database reads
6
+
7
+ A page that isn't in the edge cache yet is built by the Worker, and every D1 round trip costs it about 25–30 ms. The pack keeps its share to about one:
8
+
9
+ - **Settings with the switches.** The feature switches, the settings page renders need (schema, videos player, discovery, code theme, review style…), the deployed version and the settings generation are one query, cached per Worker isolate for five minutes. A save through the pack's admin pages applies at once in the isolate that made it.
10
+ - **Settings generation.** Every successful pack admin save writes a new generation (`plugin:coywolf-pack:state:settingsGeneration`). Other isolates see it on their next read of the switches (within five minutes) and drop everything they derived from settings (schema config, player settings, redirect patterns…), which otherwise lasts up to ten minutes. Because those isolates may render pages with the old settings in the meantime, a save that clears the page cache clears it once more about five minutes later (see [Page cache](performance.md#page-cache)). Changes made outside the pack's admin pages (a byline's profile in EmDash) show up within ten minutes.
11
+ - **One query per entry.** An entry's Schema override, Videos embed index and AI entities are one query (and its blocks, for review schema, when the theme didn't pass the entry), read once per request however many hooks ask.
12
+ - **Batched.** The pack reads D1 through the raw binding (`env.DB`), which EmDash's per-request session (and its `coalesce` option) never sees, so reads the pack starts in the same moment are sent as one `db.batch()`: one round trip. A request's redirect lookup goes in the same batch as the feature switches when those aren't cached; on a page, the entry's data, image dimensions and video details are another.
13
+ - **From the theme.** Pass the entry you rendered as `coywolf.entry` on the page context (see [Schema & Social](schema.md#pass-the-entry-to-save-reads)) and the pack doesn't read its bylines or blocks again.
package/docs/links.md ADDED
@@ -0,0 +1,34 @@
1
+ # Link Manager
2
+
3
+ [Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
4
+
5
+ An inventory of every link in your content, ported from Coywolf SEO's Link Manager:
6
+
7
+ - **Inventory**: link marks in Portable Text (including nested blocks, columns and tables), linked images, buttons, embeds, iframes, and URL fields. Each link shows its anchor text, internal or external, and the entries that use it, with links to edit them. Entries are re-indexed when they're saved, deleted or restored; **Scan content** (and each scheduled run, when **Scheduled link checking** is on) indexes everything else.
8
+ - **Checking** (sub-feature, off by default): nothing runs in the background until **Scheduled link checking** is on. Then a scheduled run starts **weekly** (or **daily** or **monthly**: **How often** in **Link Manager → Settings**), scans the content, then checks the links that are due, continuing hourly until it's done. Each check is HEAD first, then GET when HEAD is refused, 10-second timeout, up to 5 redirects recorded. Statuses are **OK**, **Redirect** (with where it ends up), **Broken** (4xx/5xx), **Blocked** (403, 429, LinkedIn's 999, or a Cloudflare/AWS/DataDome bot challenge: the link is probably fine but can't be verified from a server), and **Error** (DNS, TLS, timeout, too many redirects). Within runs, broken links and errors are due again after a day, the rest after a week. **Check now** and **Recheck** run checks on demand.
9
+ - **Bulk actions**: **Replace** a URL across every entry that uses it (link text and other formatting are kept), **Unlink** (the text stays; linked images and buttons lose their link; embeds are left alone), **Ignore** a URL or domain, and **Recheck**. Ignore rules can also be domains, exact URLs, wildcards (`https://coywolf.com/visit/*`) or regular expressions. Ignored links aren't checked or counted.
10
+ - **Export CSV**: every link matching the current filters (all pages), with its status, HTTP code, final URL, the entries that use it, and their anchor text. Filter by **Broken** first for a list to hand to whoever fixes links. Cells that look like spreadsheet formulas are prefixed with an apostrophe.
11
+ - **Dashboard widget** with the number of broken links.
12
+
13
+ Feature switches: **Link Manager** (`links`) and **Scheduled link checking** (`links.check`), both off by default.
14
+
15
+ ## Setup
16
+
17
+ No bindings or secrets, but EmDash needs to know the site URL (**Settings → General**, or `site` in `astro.config`) to tell internal links from external ones; nothing is indexed until it's set. Settings (**Link Manager → Settings**): how often scheduled runs start (daily, weekly or monthly; default weekly), subrequests per check run (default 40), whether to check links to your own site (default on), and a User-Agent override. The checker presents a current desktop Chrome by default, which avoids most false "Blocked" results.
18
+
19
+ ## Notes
20
+
21
+ - Edits go through EmDash's content API. A published entry is republished so the fix goes live. An entry with unpublished changes (or a schedule) is fixed in its draft only, so its live version keeps the old link until the draft is published (the result message says how many). An entry saved by someone else while the action runs is left alone and reported, so run the action again for it. An editor with the entry open may still overwrite the change on their next save.
22
+ - The inventory follows each entry's latest saved version (its pending draft, if it has one). Links inside raw HTML blocks aren't tracked.
23
+ - Workers limit subrequests per invocation (50 on Free, 1,000 on Paid), and database calls count. A checked link takes 1–2 requests plus 1 per redirect, and 1 database write; keep **subrequests per check run** under your plan's limit, leaving room for other scheduled jobs. Scheduled runs work in hourly steps (scan steps of about 200 database statements or 60 seconds, then one check run of **subrequests per check run** each hour), so on a big site a run can take several hours or more; raise subrequests per check run on the Paid plan to finish sooner. While Scheduled link checking is off, the hourly tasks read only the feature switches; between runs, they also read one small state row and the frequency. A scan started from the page runs faster while the page is open; admin actions stop at about 150 statements or 25 seconds and continue on the next call.
24
+ - Links are stored in plugin storage (`links_urls`, `links_refs`) in the site's D1 database, so backups include them. The list pages through indexed queries; search matches the start of a URL (when it starts with `http` or `/`) or of a domain. Indexing an entry looks up its references with one D1 query on the `entryKey` expression index, with no sort (storage queries always sort by creation time, and for that SQLite picked the generic list index and read every reference row of the site, once per entry scanned). A scan writes only references that are new or changed (an unchanged entry costs that one read and no writes), drops links removed from an entry as it indexes it, and then removes the references of entries it can't visit (deleted, trashed, or in a collection without link fields now): one query lists the entries that have references, and one per 90 of them checks they still exist. Status counts are cached for up to a minute (10 minutes in the dashboard widget).
25
+ - Regular-expression ignore rules are limited to 200 characters, and patterns that can take exponential time (repeated groups containing a quantifier or alternation, backreferences) are refused.
26
+ ## Checking internal links
27
+
28
+ A Worker can't fetch its own custom domain over HTTP (Cloudflare answers 522), so internal links are checked through a service binding to the site's own Worker. Add it to `wrangler.jsonc` (use your Worker's `name`):
29
+
30
+ ```jsonc
31
+ "services": [{ "binding": "SELF", "service": "coywolf" }]
32
+ ```
33
+
34
+ Without the binding, internal links are listed but not checked (they're never reported as broken).
@@ -0,0 +1,72 @@
1
+ # Performance
2
+
3
+ [Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
4
+
5
+ ## Faster sites
6
+
7
+ EmDash on Cloudflare builds every page in a Worker, and a Worker that hasn't run recently in a data center has to start up, load EmDash and query D1 before it can answer. On coywolf.com that meant 1.1–2 seconds before the first byte and a mobile PageSpeed Speed Index of 5–8 seconds. With Coywolf Pack's performance features, the same site scores 100 in every PageSpeed category.
8
+
9
+ | What it improves | How |
10
+ | --- | --- |
11
+ | **First visits** | Keeps Cloudflare's Workers Cache (the edge HTML cache in front of the Worker) correct, so pages can stay cached for days: it clears everything after each deploy and whenever pack settings change, and sets page lifetimes at runtime. Cached pages answer in 60–90 ms instead of 1–2 s. |
12
+ | **Clearing the cache** | **Plugins → Performance → Clear pages and images**. Cloudflare's zone "Purge Everything" doesn't reach Workers Cache, which belongs to the Worker. Settings that don't change pages (backups, the link report) don't clear it, redirect edits clear only cached redirects, and robots.txt rules clear only `/robots.txt`. |
13
+ | **Cold pages after a clear** | Optional cache warming: after a deploy, a full clear or (a minute later) content edits, every page in the sitemap, plus the section pages the home page links to (category archives sitemaps often leave out), is visited in the background of the site's own traffic (home page and its menu pages first, then newest posts), so visitors and crawlers get cached pages instead of waiting for them to be built. |
14
+ | **Images** | Serves media from a `media.` subdomain (R2 custom domain + Image Transformations), never through the Worker, with clean resized URLs, a one-year Cache Rule, responsive `srcset` helpers, and width/height lookups for images that have none (WordPress imports), so layouts don't shift. |
15
+ | **Live search** | Results as you type in tens of milliseconds: an instant title index in the browser, an edge cache keyed to content changes, and 2–3 D1 queries instead of 25–31. |
16
+ | **Redirects** | Answered by middleware from rules cached per isolate, before EmDash renders anything, then kept in the edge cache (tagged `coywolf-redirects`) until a redirect edit clears them. |
17
+ | **Fewer D1 round trips** | Pages that aren't cached yet cost the pack about one D1 round trip: settings come with the feature switches' query (cached per isolate for five minutes), an entry's pack data is one query, and the pack's own reads of a moment go to D1 as one batch. See [Database reads](internals.md). |
18
+ | **No client JavaScript where it isn't needed** | Code highlighting, tables of contents, breadcrumbs, reviews and schema are rendered on the server. |
19
+
20
+ See [Page cache](#page-cache) for setup.
21
+
22
+ ## Page cache
23
+
24
+ For sites that put Cloudflare's [Workers Cache](https://developers.cloudflare.com/workers/cache/) in front of the Worker, which serves cached pages without running the Worker. That's what makes first visits fast: a cold Worker spends a second or more starting up and querying D1. Turn it on in the site, as in EmDash's Cloudflare guide:
25
+
26
+ ```js
27
+ // astro.config.mjs
28
+ import { cacheCloudflare } from "@astrojs/cloudflare/cache";
29
+ export default defineConfig({
30
+ cache: { provider: cacheCloudflare() },
31
+ routeRules: { "/": { maxAge: 3600, swr: 86400 }, "/[...path]": { maxAge: 3600, swr: 86400 } },
32
+ });
33
+ ```
34
+
35
+ and add `"version_metadata": { "binding": "CF_VERSION_METADATA" }` to `wrangler.jsonc`.
36
+
37
+ EmDash clears the pages it tagged when content, menus or site settings change. **Plugins → Performance** (always on, right below Coywolf Pack; on a site without Workers Cache it does nothing) covers the rest:
38
+
39
+ - **After a deploy**: the first request a new Worker version handles clears every cached page, so theme and code changes show up right away.
40
+ - **After Coywolf Pack settings change**: a successful admin save that changes page output (schema, blocks, videos, images, feature switches…) clears every cached page (see Scoped clearing below). Other Worker isolates may keep the old settings for up to five minutes (see [Database reads](internals.md)), so pages they render meanwhile could be cached with them: about five minutes after the save, the first request in any isolate clears the same pages once more (a "settling" clear; with warming on, it restarts the warm-up).
41
+ - **Page cache lifetimes**: how many days the edge keeps a page (default 7) and how many more days it may serve it while fetching a fresh copy in the background (default 1). They replace the `routeRules` lifetimes on the routes the site made cacheable, at runtime, so no deploy is needed. Routes without a route rule are never cached by this.
42
+ - **After going live**: EmDash's staging mode (EmDash 1.5+) puts `noindex, nofollow` on every page until the site goes live. Going live, or switching back to staging, under **Settings → General → Site Status** clears every cached page (and restarts warming), so search engines don't get cached pages with the old robots tag.
43
+ - **Media cache**: shows whether the media host (Clean Image URLs) has a Cloudflare Cache Rule, and **Cache media for a year** adds one (edge and browser TTL one year; media file names never change). Needs the Clean Image URLs token with **Zone → Cache Rules → Edit**. Other cache rules on the zone are kept.
44
+ - **Cache warming** (off until an admin turns it on): after a deploy or a full clear, the pack reads `/sitemap.xml` (following a sitemap index) and visits every page, home page first, then the pages the home page links to (menus and section pages such as category archives, which sitemaps often leave out; up to 200, same site only, no feeds, files or query strings), then in sitemap order. The work rides on real traffic: after the site answers a request, the Worker claims a few queued pages and visits them in the background through its own `SELF` service binding, so the warm-up runs where readers and crawlers are (with Smart Placement, near the database) and fills that region's cache tiers. It doesn't use a Cron Trigger: those run in whatever data center has spare capacity, often on another continent, and placement hints don't apply to them. Batches are claimed atomically, so parallel isolates never repeat pages; a new clear or deploy restarts the run. The run's URLs are stored once (`plugin:coywolf-pack:pageCache:warmQueue:<run>:<n>` option rows of up to 500 URLs, removed when the run finishes or a new one starts; a batch claimed and never recorded, because its isolate stopped, counts as failed after two minutes so the run still finishes) and each claim only updates a small progress row, so even a 5,000-page run writes little per batch. Pages whose render used a stopgap (a video poster still being copied to the media host, see **Posters from your media host**) aren't left cold: the warmer's render of such a page isn't cached (the middleware answers it with an internal `X-Coywolf-Stopgap: 1` header, which is never stored in the cache, so visitors don't see it), and the page is listed in the progress row (up to 200, same site only) and visited again once the rest of the run is done and 20 seconds have passed, by which time its posters are copied, so it's cached with the normal lifetime. A page still on a stopgap is tried at most twice more, then given up on (it's counted, the run still finishes). The list belongs to its run: a new clear or deploy starts over. The page shows progress ("Re-warming N pages after their video posters were copied" while revisits are pending), and **Warm now** starts a run by hand. Needs a `services` binding named `SELF` pointing at the Worker itself.
45
+ - **Warming after content edits**: EmDash clears cached pages by tag when content, menus, taxonomies, widgets or site settings change, and list pages carry their collection's tag, so saving or publishing one post clears the home page, the category archives and many posts. With warming on, the pack middleware notices those clears (it wraps the request's `cache.invalidate`, so it fires exactly when EmDash clears something, through the admin, its API or MCP; draft saves that don't change live content clear nothing and start nothing) and starts a run a minute after the last such write: each write pushes the start back a minute, up to five minutes after the first, so a burst of edits makes one run. The schedule is a `rewarmAfter` time in the progress row; the next warm step after it starts the run (reason "edit"), replacing a run still under way (pages it already warmed are cache hits, so they cost little). The Performance page shows "Content changed: warming again at about …" meanwhile. The warmer's own visits are GET requests, which never schedule a run.
46
+ - **Daily refresh**: Cloudflare doesn't keep every page for its full cache lifetime; pages that aren't requested often are dropped from a data center's cache early, so a search bot crawling older posts, or a visitor arriving from a search result, would wait for those pages to be built. With warming on, a run also starts once a day: the first warm step more than 24 hours after the last run finished (whatever started it) starts a new one (reason "daily"; on a site with no run yet, the first page view starts one). It visits the same pages in the same order as any other run; pages still cached come back as cache hits and cost little (no database queries), so only the pages Cloudflare dropped are built again. A deploy, clear, edit or **Warm now** run resets the clock, since the next refresh is due a day after the last run finished. The due time comes from the progress row's `finishedAt`, which requests already have with the feature switches, so requests in between still skip the warm step without a query, and the run starts through the same atomic claim as the others, so parallel isolates start it once. The Performance page shows "Next daily refresh: about …" while warming is idle. The refresh is on by default; uncheck **Refresh daily** under Cache warming (setting `pageCacheWarmDaily`, read with the other switches, so it adds no query) to turn it off: no daily runs then (and no first run on a site that has none), while deploys, clears, edits and **Warm now** still start runs.
47
+ - **Scoped clearing**: saving settings that don't change pages (backups, the link report, the Performance page's media and warming controls) clears nothing; redirect edits (including resolving removed content, imports, and a database rewind or undo) clear only cached redirects (tag `coywolf-redirects`, cleared again about five minutes later like other saves); robots.txt rules clear only `/robots.txt`. Everything else clears every page.
48
+ - **Clear pages and images**: clears every cached page and redirect now, plus the media host's images (originals and resized copies) from the zone cache. Cloudflare's zone **Purge Everything** doesn't reach Workers Cache, which belongs to the Worker. Clearing images uses the Clean Image URLs token, which needs **Zone → Cache Purge → Purge**.
49
+ - **Pages rendered after a failed read aren't cached**: when the pack can't read what it needs (D1 unreachable for a moment, a query error), it fails closed for that request. The feature switches fall back to their defaults (most are off), settings read as unset, redirect and robots.txt rules as absent, and a module whose page metadata failed (or ran into EmDash's 5-second hook timeout) adds none. The page still goes out, but without its schema, reviews, videos or redirects, so it isn't put in the edge cache, where it would stay for the Page cache lifetime. The next request renders it again. Each such fallback is counted (`noteReadFailure()` in `src/core/read-failures.ts`). The middleware compares the count before the request and after the response has rendered, and turns route caching off (`cache.set(false)`) when it moved. For a cacheable HTML page, that happens after the page is read in full: Astro streams pages, so a component can still read settings, or call `cache.set()` (which would turn caching back on), after the headers are ready. The log says `coywolf-pack: GET /path/ not edge-cached: a read failed while it rendered (last failure: …)`, naming what failed (for example `schema: page:metadata`). A module that fails on every render (a bug rather than a blip) keeps every page out of the cache until it's fixed, so a steady stream of these lines means something to fix, not a D1 problem. A plugin route answering "not found" (a feature that's off, or the plugin not enabled) isn't a failure; only server errors are. The count is per isolate, so a failure while another page renders at the same moment keeps that page out of the cache too, which only costs one render. A failed read is never remembered as the answer: the switches, the schema configuration, a page's reviews and discovery files are read again on the next request.
50
+
51
+ Responses the pack serves itself (robots.txt, llms.txt, files, clean image URLs) keep their own `Cache-Control`: the middleware turns Astro's route caching off for them, so a site route rule (for example on a catch-all page route) doesn't add its edge lifetime. Redirects set their own: see [Redirects](redirects.md).
52
+
53
+ ### robots.txt and the live search script at the edge
54
+
55
+ Crawlers ask for `/robots.txt` constantly, and each answer used to run the Worker. It's now kept in the edge cache for a day, whether the pack's Robots.txt Rules or EmDash's own file serves it (tag `coywolf-robots`; the pack clears its cache after its admin saves and settings changes, so edits show at once). A `robots.txt` asked for below the root (`/news/robots.txt`, which crawlers try) gets a small plain-text 404, also cached for a day, instead of a rendered 404 page. The live search client (`/_emdash/api/plugins/coywolf-pack/search/live-client?v=<hash>`) is kept at the edge for a year when the URL names the current version (5 minutes otherwise); the live results keep their own short cache.
56
+
57
+ ### Cloudflare usage
58
+
59
+ The Performance page's **Cloudflare usage** card, and a **Cloudflare usage** dashboard widget, show what the site and its account use on Cloudflare, read from Cloudflare's analytics and kept for an hour (**Refresh** reads again):
60
+
61
+ - **The estimated bill**: Workers Paid's $5 a month plus anything over the included amounts, worked out from the account's last 30 days (allowances are shared by every Worker and database in the account). On Workers Free it compares today (UTC; the limits reset at 00:00 UTC) with the daily limits instead.
62
+ - **The account against its plan**: Workers requests and CPU time, D1 rows read and written, and D1 storage, with the share of each allowance. The plan is read from the account's entitlements (D1's per-database size limit is 10 GB on Paid, 500 MB on Free); without them, a day over Free's limits shows the account is on Paid, and otherwise Paid is assumed.
63
+ - **This site**: its Worker's requests and CPU time and its D1 database's rows read and written, queries and size, for today, the last 7 days and the last 30 days (UTC dates, including today). The Worker is the one whose current deployment runs this version (the `CF_VERSION_METADATA` binding), and the database is the D1 binding named `DB` in that Worker's settings, so nothing is configured per site.
64
+ - **Cache warming (estimate)**: the warmer's visits over the last 7 days from Workers Logs, as a share of the site's requests and CPU time, and its D1 rows read (the site's total times that share). Logs are sampled, so it's labeled as an estimate.
65
+
66
+ It needs a read-only, account-owned API token (Cloudflare dashboard → Manage Account → Account API Tokens → Create Custom Token):
67
+
68
+ - Account → Account Analytics → Read (D1 and Workers usage)
69
+ - Workers → Metadata Read-Only, for All Workers (finds the site's Worker and database, and reads Workers Logs for the warming estimate; on a personal token: Account → Workers Scripts → Read and Account → Workers Observability → Edit)
70
+ - Account → Account Settings → Read (the plan, from the account's entitlements)
71
+
72
+ Set it as the `USAGE_API_TOKEN` Worker secret (one token works for every site in the account), or save it as **Cloudflare usage** on the plugin's Settings page. The account comes from `CF_ACCOUNT_ID`, the Clean Image URLs account ID, or the token itself when it's limited to one account. A part the token can't read is left out with a note naming the permission; the rest still shows.
@@ -0,0 +1,84 @@
1
+ # Redirects
2
+
3
+ [Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
4
+
5
+ EmDash's built-in Redirects (**Manage → Redirects**) handle site-relative page redirects. This module covers what they can't, and leaves the rest to EmDash. Compared with EmDash 1.3:
6
+
7
+ | | EmDash Redirects | Coywolf Pack Redirects |
8
+ |---|---|---|
9
+ | External destinations (affiliate links such as `/visit/partner`, articles that moved to another site) | No: destinations must be site paths | Yes |
10
+ | File paths (old WordPress `/wp-content/uploads/` image URLs, `/file.pdf`) | No: its middleware skips any path with a file extension | Yes |
11
+ | Patterns | Whole-segment placeholders only (`/old/[slug]`, `/docs/[...rest]`) | Regular expressions with `$1`–`$9` substitution |
12
+ | Query strings | Dropped | Passed on to the destination (unless it has its own) |
13
+ | Bulk import and export | No (only seed files and full-site transfers) | Paste or upload JSON, CSV or TSV (a Coywolf SEO export from WordPress works as is); export as CSV or JSON |
14
+ | URL tester | No | Yes |
15
+ | Notes | A group label | A note per rule |
16
+ | Trashed or unpublished entries | Nothing | Prompt for a redirect or 410, undone when the entry comes back (see [Removed content](#removed-content)) |
17
+ | Changed slugs, categories or parent pages | A redirect built from the collection's URL pattern (posts without a pattern get `/posts/<slug>`, which isn't a real URL; category and parent paths are never covered) | A 301 from the old public URL to the new one (see [Slug changes](#slug-changes)) |
18
+ | Edge caching | Doesn't cache redirects (checks its rules before a page is cached) | Kept in Workers Cache for 30 days, cleared on any redirect change, along with the pages at new sources |
19
+ | Status codes, trailing slashes, hit counts, enable/disable | Yes (also 451) | Yes (301/302/307/308/410) |
20
+
21
+ Left to EmDash, which does them and the pack doesn't: **automatic redirects when an entry's slug changes**, the **404 log** with "create redirect from this 404", and **loop detection** across rules. When both have a rule for the same path, EmDash's wins (its middleware runs first).
22
+
23
+ Features: exact paths (with or without a trailing slash) or regular expressions with `$1`–`$9` substitution, 301/302/307/308/410, enable/disable, notes, hit counts, a URL tester, bulk import (paste or choose a file: JSON, or tab/comma-separated `source, target, type, is_regex` rows; a Coywolf SEO export from WordPress works as is), and **Export** as CSV or JSON. Rules are stored in the site's D1 database (`coywolf_redirects`), so backups include them.
24
+
25
+ A capture group can't send visitors to another site: with a site-path destination such as `/$1`, leading slashes from the capture are collapsed (`/blog//evil.com` → `/evil.com` on your site, never `//evil.com`), and a destination that names its host (`https://coywolf.com$1`) only redirects when the result keeps that host. A destination whose host is itself a capture (`https://$1/`) is followed as written.
26
+
27
+ ## Setup
28
+
29
+ Add the middleware to `src/middleware.ts` (step 3 of [Getting started](../README.md#getting-started); `coywolfRedirects` is its earlier name and still works):
30
+
31
+ ```ts
32
+ import { sequence } from "astro:middleware";
33
+ import { coywolfPack } from "@coywolf/emdash/middleware";
34
+
35
+ export const onRequest = sequence(coywolfPack(), /* your middleware */);
36
+ ```
37
+
38
+ Each request looks up its exact rule in D1: one indexed read of the request path (trailing slash removed), so an exact rule edit applies at once and the database reads at most one row however many rules there are. Pattern rules (usually few) are kept in memory per Worker isolate, in source order, and read again after a redirect edit (at once in the isolate that saved it, in others within five minutes; see [Database reads](internals.md)) or after ten minutes, through their own index (`coywolf_redirects_patterns`, added to existing sites the next time the Redirects page opens). Exact rules win over patterns. When the feature switches aren't cached, the lookup goes in the same D1 batch as them. Serving never writes: the `coywolf_redirects` table is created by the first admin write (saving or importing a rule), and until then there are simply no rules. Hits are counted after the response is sent.
39
+
40
+ **Edge caching.** With Astro's route caching on (`cacheCloudflare()`), GET and HEAD redirects (and 410s) are kept in the edge cache (Workers Cache) for 30 days, keyed by path and query string, and tagged `coywolf-redirects`. Any redirect change (saving, deleting, enabling or disabling, importing, resolving removed content, rewinding the database) purges that tag, as does **Clear pages and images** and every deploy. Browsers keep a redirect for an hour (`Cache-Control: max-age=3600`); the edge lifetime and tag go in `Cloudflare-CDN-Cache-Control` and `Cache-Tag`, which Cloudflare strips before the response reaches the browser. Cached site-path redirects carry a site-relative `Location`, since the edge cache is shared by every hostname the Worker answers. Repeat requests served from the cache never reach the Worker, so **hits count requests the Worker answered**, not every visit. Without route caching, redirects send `Cache-Control: private, max-age=3600` and are never edge-cached.
41
+
42
+ `coywolfPack()` takes no redirect options; `serveRedirect(url, env, waitUntil, { cacheSeconds })` (ten minutes by default) is the longest time to keep pattern rules.
43
+
44
+ ## Removed content
45
+
46
+ Feature **Redirects → Removed content** (`redirects.trashPrompt`, off by default). When a published entry is trashed or unpublished, the admin asks right away what its old URL should do:
47
+
48
+ - **Redirect to…** creates a Coywolf redirect rule (301 by default) from the old URL.
49
+ - **Return 410 Gone** creates a 410 rule, telling search engines the page is gone for good.
50
+ - **Decide later** (or Escape) closes the prompt. Until you decide, the old URL returns 404.
51
+
52
+ Trashing several entries at once (bulk trash) asks once, listing each of them. Undecided URLs stay listed in a **Removed content** panel on the Redirects page, with the same choices plus **Dismiss**, which forgets an entry (for example, when the URL should simply 404).
53
+
54
+ The old URL is resolved the way EmDash resolves it: the collection's URL pattern (`{slug}`, `{id}`, and date tokens from the publish date), or `/<collection>/<slug>`. Locale prefixes aren't added. Drafts that were never published aren't listed or prompted for. Restoring an entry from the trash (it comes back as a draft) or republishing it removes it from the list and **undoes the rule its decision created**, as long as that rule still has its `Removed:` note and the same source; a republished entry must also be back at the same URL (a new slug keeps the redirect from the old one, and a later publish back at the old URL still undoes it). A redirect that already exists for the old URL is never overwritten or adopted: the decision says so, and you can keep it (Dismiss the entry) or change it under Redirects. An entry that turns out to be live after all (its trash failed, or it came back some other way) is forgotten the next time the list is read. Decisions are kept in plugin storage (`redirects_removed`), and the rules they created in `redirects_applied`. The module declares the `content:read` capability for the delete and publish hooks.
55
+
56
+ How the prompt knows: EmDash has no admin event for a trash or unpublish, so the pack's admin module watches the admin's own content API calls (`DELETE /_emdash/api/content/<collection>/<id>` and `POST …/<id>/unpublish`), never altering or reading their responses, then asks the server which of those entries it recorded. The trash is recorded before EmDash answers the request; an unpublish is recorded just after, so the prompt looks again for up to about two seconds when an unpublish isn't listed yet (trashed drafts don't hold it up). If a future EmDash release changes those API paths, the prompt stops appearing, but the Removed content list on the Redirects page keeps working.
57
+
58
+ ## Slug changes
59
+
60
+ Feature **Redirects → Slug changes** (`redirects.slugChanges`, on by default). When a published entry's public URL changes, because of a new slug, category or parent page, the pack adds a 301 from the old URL to the new one, noted `Slug change: <title>`. The URLs come from the pack's URL resolver (the one the sitemaps and breadcrumbs use, with the site's `urls`, `termParents` and `pageParents` options), so they're the URLs readers and search engines saw.
61
+
62
+ - **How it knows the old URL.** Each published entry's URL is remembered in plugin storage: recorded from the live row before a save, and after every save or publish. An entry that hasn't been saved since the feature was turned on is recorded at its first save, before anything changes.
63
+ - **No chains or loops.** Renaming A → B → C points the A rule at C. Renaming back to A deletes the A → B rule, which would otherwise shadow the live page.
64
+ - **Hand-made rules win.** If a rule someone made already answers the old URL, it's left as is (and logged). Only rules noted `Slug change:` are ever edited or deleted.
65
+ - **Limits.** Translations are skipped (they can share a slug with the default-language entry, so a rule could shadow it); only default-language entries get rules. Recording happens in `content:beforeSave`, which EmDash runs only when a save carries content fields, and within a 1.5-second budget (EmDash fails a save whose beforeSave hook times out), so a slug-only API update to an entry never saved since the feature was turned on isn't caught. A category changed through EmDash's separate terms endpoint fires no content hook; its redirect is made at the entry's next save or publish.
66
+ - **A URL that's live again is freed.** When an entry is published at a URL that a slug-change rule redirects (another entry took the old slug, or this one moved back), that rule is deleted.
67
+ - **Replaces EmDash's own.** EmDash writes a pattern-based redirect for the same change (its Redirects, group "Auto: slug change"). Without a URL pattern its paths don't exist (`/posts/<slug>`); for a nested page it writes a top-level `/<old>` that can hijack a real page there; and its middleware answers before the pack's, without the trailing slash. When the pack makes its rule, it deletes EmDash's auto row for the same change (source ending in the old slug, destination ending in the new one). EmDash's in-memory redirect cache may serve that row for a short while in other isolates.
68
+
69
+ ## Export redirects from WordPress (Coywolf SEO)
70
+
71
+ ```bash
72
+ wp db query "SELECT source, target, type, is_regex FROM wp_coywolf_seo_redirects"
73
+ ```
74
+
75
+ Paste the output into **Redirects → Import**.
76
+
77
+ ## Export and move redirects
78
+
79
+ **Export** (next to Import) downloads every rule as CSV (for spreadsheets) or JSON. Either file imports back on this or any other site through **Import → Choose file**, updating rules with the same source:
80
+
81
+ - CSV columns: `source, target, type, is_regex, enabled, note`, then `hits, last_hit, created_at, updated_at` for reference (Import ignores them, so hit counts start over on the new site). Cells that a spreadsheet would run as a formula (starting with `=`, `+`, `-` or `@`) get a leading apostrophe, which spreadsheets hide; Import removes it again, so the round trip is exact.
82
+ - JSON: an array of rules, as the list route returns them.
83
+
84
+ Import takes up to 5,000 rules (2 MB) at a time; split bigger files.
@@ -0,0 +1,21 @@
1
+ # Releasing
2
+
3
+ [Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
4
+
5
+ Each release is a version tag, a GitHub release and an npm version of `@coywolf/emdash`.
6
+
7
+ 1. Bump `version` in `package.json` and `VERSION` in `src/version.ts` (they must match), and run `npm install --package-lock-only`.
8
+ 2. Merge to `main`, then tag the merge commit: `git tag -a v0.35.14 -m "…"` and `git push origin v0.35.14`.
9
+ 3. Publish the GitHub release for the tag (`gh release create v0.35.14 --latest --verify-tag --notes-file …`), with the intro, the five bullets, **What's new** and the install guide.
10
+ 4. Publishing the release runs [`.github/workflows/publish.yml`](../.github/workflows/publish.yml): it checks that the tag matches `package.json`, runs the type check and tests, and publishes to npm with a provenance statement. Run it again by hand from the Actions tab (**Publish to npm → Run workflow**, with the tag) if it failed.
11
+
12
+ ## npm setup (once)
13
+
14
+ The workflow uses npm trusted publishing: npm accepts publishes from this repository's workflow without any stored token.
15
+
16
+ 1. The npm account `coywolf` owns the `@coywolf` scope (a user's scope; no organization needed).
17
+ 2. The first version is published by hand, because npm sets up a trusted publisher only for a package that exists. From a clean checkout of the release tag, signed in to npm as `coywolf`: `npm publish --access public` (npm asks for the two-factor code).
18
+ 3. On npmjs.com, open `@coywolf/emdash` → **Settings** → **Trusted publishing**, choose **GitHub Actions**, and enter organization `coywolf-llc`, repository `coywolf-pack`, workflow `publish.yml` (no environment). Done for 0.35.13; 0.35.14 was the first version the workflow published.
19
+ 4. Optional, recommended: in the package's **Settings → Publishing access**, choose **Require two-factor authentication and disallow tokens**. Trusted publishing keeps working.
20
+
21
+ npm reviews a new package's first versions before they're public: until then `npm view` reports the package as not found and the package page shows a "Temporary Holding Version". A new trusted publisher shows **Pending validation** until the workflow publishes through it once.
@@ -0,0 +1,89 @@
1
+ # Reviews
2
+
3
+ [Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
4
+
5
+ The review box Coywolf uses on WordPress (Coywolf Custom Blocks' review block): a rating badge with "4.5 out of 5" under it, then **What I liked most** and **Could be better** lists side by side, stacking on narrow screens. It adds Review schema that Google can read for review snippets and product pros and cons.
6
+
7
+ - **Coywolf Review block** (slash menu → Content): item name, item type (the types Google accepts for reviews: Product, Software application, Book, Course, Movie, Game, Event, Recipe, Local business, Organization, and a few more), brand, item URL, item image (for schema only), book details (author, ISBN, publisher, genre, copyright year) and software details (operating systems, app category) for schema, rating (0–5 in tenths, e.g. 4.7), the two headings (defaults above), pros and cons (one per line), an optional summary or verdict, and the heading level (H2 by default, like WordPress). It's server-rendered plain HTML with no script. Its CSS is about 2 KB, inlined once per page before the first box, so nothing shifts.
8
+ - **Ratings** are picked from a menu of 5.0 down to 0.0 in steps of 0.1 (EmDash's number field only takes whole numbers). The badge and caption show the rating as stored ("4.7 out of 5", "5 out of 5"), and Review schema's `ratingValue` is the same number. Reviews saved before 0.11.0 with half steps keep working; saving the entry stores them in the new format (`"4"` becomes `"4.0"`) so the menu shows them.
9
+ - **Reviews page** (**Plugins → Coywolf Pack → Reviews**): the badge's accent color (default `#2C8452`; badge text is white), **Custom CSS**, and a live preview of a sample review, wide or at phone width. The preview runs in a sandboxed frame, so your CSS can't restyle the admin.
10
+ - **Accessible**: the box is a `<section>` labeled "Review of <item>". The badge number is hidden from screen readers, which read "Rated 4.5 out of 5" instead. The lists are real headings and lists.
11
+
12
+ | Feature | Default | What it adds |
13
+ | --- | --- | --- |
14
+ | `reviews` | off | The block on the site, and the Reviews page |
15
+ | `reviews.schema` | off | Review JSON-LD for each review, in Schema & Social's graph when it's on, otherwise one standalone script per review (`page:metadata`, ids `coywolf-review-N`) |
16
+
17
+ ## Customizing the look
18
+
19
+ Every part has a stable class: `.cw-review` (the card), `__rating`, `__badge`, `__caption`, `__columns`, `__col` (plus `__pros` / `__cons`), `__heading`, `__list`, `__item` and `__summary`. Every design value is a CSS custom property. The built-in CSS only reads them, with fallbacks, so you can set them on `.cw-review`, on a wrapper, or on `:root`:
20
+
21
+ | Property | Default | |
22
+ | --- | --- | --- |
23
+ | `--cw-review-accent` | the Reviews page color | Badge background |
24
+ | `--cw-review-badge-color` | `#fff` | Badge text |
25
+ | `--cw-review-badge-size` / `-badge-padding` / `-badge-radius` | `3.5rem` / `.5rem 1rem` / `10%` | |
26
+ | `--cw-review-bg` / `--cw-review-bg-dark` | `#fff` / `#1d1f23` | Card background (light / dark); `--cw-review-bg` alone sets both |
27
+ | `--cw-review-border` | `1px solid` + the border color | Whole border shorthand (e.g. `0`); overrides the colors below |
28
+ | `--cw-review-border-color` / `--cw-review-border-color-dark` | `#dfe0e3` / `#3a3d44` | |
29
+ | `--cw-review-radius` / `-padding` / `-margin` | `12px` / `2rem 1rem` / `0 0 1.5rem` | |
30
+ | `--cw-review-color` / `-font` | `inherit` | Text color and font |
31
+ | `--cw-review-heading-color` / `-heading-size` / `-heading-transform` | `inherit` / `1.2rem` / `uppercase` | |
32
+ | `--cw-review-list-color` / `--cw-review-list-color-dark` / `-list-size` / `-list-style` | `#555` / `#c9ccd1` / `1rem` / `square` | `--cw-review-list-color` alone sets both |
33
+ | `--cw-review-caption-color` / `-caption-size` | `inherit` / `1rem` | The "4.5 out of 5" line |
34
+ | `--cw-review-gap` / `-column-gap` | `1rem 1.5rem` | Space between the badge and lists, and between the lists |
35
+ | `--cw-review-rating-width` | `7rem` | Badge column's minimum width |
36
+ | `--cw-review-stack-at` | `26rem` | Narrowest the lists area gets beside the badge before the badge moves above it |
37
+ | `--cw-review-column-min` | `13rem` | Narrowest a list column gets before the lists stack; `100%` keeps them in one column |
38
+
39
+ Light and dark colors use CSS `light-dark()`, so the box follows the page's own `color-scheme`: on a theme that declares `color-scheme: light dark` it turns dark with the page, and on a light-only theme (no `color-scheme`) it stays light whatever the visitor's system setting. The `-dark` properties are the dark half.
40
+
41
+ The layout uses flex and grid wrapping instead of a media query, so it adapts to the box's own width (a sidebar or a phone alike) and the "breakpoints" are the custom properties above.
42
+
43
+ Put overrides in **Custom CSS** on the Reviews page. It's added after the built-in styles on pages with a review, so it wins. Start every rule with `.cw-review` so it only reaches review boxes; it's printed as-is (not rewritten), with `</style`, `<!--` and `-->` removed and a 20 KB cap. For example, a crimson badge and one column:
44
+
45
+ ```css
46
+ .cw-review {
47
+ --cw-review-accent: #b22d47;
48
+ --cw-review-column-min: 100%;
49
+ }
50
+ .cw-review .cw-review__heading { text-transform: none; }
51
+ ```
52
+
53
+ ## Review schema
54
+
55
+ With Schema & Social's graph on, each review joins the page's `@graph` like videos do. The reviews come from the entry's `coywolf-review` blocks (any Portable Text field), followed by any the theme passes as `page.coywolf.reviews`, de-duplicated by item type and name, up to 20.
56
+
57
+ - **Products** become a top-level `Product` (`<page>#review-N-item`, with name, `brand` as a `Brand`, url and image). The `Review` (`<page>#review-N`) is nested in it as `review`. Google reads pros and cons (`positiveNotes` / `negativeNotes`, each an `ItemList` of `ListItem`s with position and name) only from a Review nested in a Product, and a nested review needs no `itemReviewed`.
58
+ - **Other types** become a top-level `Review` with the item in `itemReviewed`. That's the shape Google's review-snippet docs show, and it avoids validating, say, an Event or SoftwareApplication as its own rich result.
59
+ - **Pros and cons** are emitted only for Product reviews with at least two statements in all (Google's minimum); they still show in the box either way.
60
+ - **Books and software** get their details on the item: a Book's `author` (Person), `isbn`, `publisher` (Organization), `genre` and `copyrightYear`; a SoftwareApplication's `operatingSystem` and `applicationCategory`.
61
+ - **Every review** has a `reviewRating` (`Rating`, `ratingValue`, `bestRating` 5, `worstRating` 0, because the scale starts at 0), the Article's author(s) as `author` (else the publisher), the graph's publisher, the Article's `datePublished`, `mainEntityOfPage` → the WebPage, and the summary as `reviewBody`. The item is added to the Article's (or WebPage's) `about`, alongside AI Enrichment's entities.
62
+ - **Not emitted**: reviews without an item name or a rating, and "self-serving" reviews, which Google ignores. Those are an Organization or Local business whose URL is on your own site or whose name is the publisher's.
63
+
64
+ With the graph off, each review is a standalone JSON-LD script in the same shape. The author is the page's byline name (else the site), and the publisher is the site. Some item types need more properties for their own rich results (an Event's date and location, for example). The block doesn't collect those, so validate important pages in Google's Rich Results Test. Google's product snippets focus on one product per page: several Product reviews on one page are all output, but Google may use only one of them.
65
+
66
+ ## Legacy WordPress reviews
67
+
68
+ Themes that render imported WordPress review markers (`{ name, brand, rating, strengths, shortcomings }`, with the lists as `<ul>` HTML) can use the same box and schema:
69
+
70
+ ```astro
71
+ ---
72
+ import { Review, reviewFromAttrs } from "@coywolf/emdash/astro";
73
+ ---
74
+ <Review attrs={markerAttrs} /> <!-- or <Review {...markerAttrs} /> -->
75
+ ```
76
+
77
+ and pass the same reviews to the page context for schema:
78
+
79
+ ```astro
80
+ ---
81
+ const reviews = reviewMarkers(post.data.content).map(reviewFromAttrs); // your marker parser
82
+ const page = { ...createPublicPageContext({ /* … */ }), coywolf: { videos, reviews } };
83
+ ---
84
+ <EmDashHead page={page} />
85
+ ```
86
+
87
+ `reviewFromAttrs` reduces the `<li>` HTML to plain text (tags dropped, entities decoded) and maps the marker attributes to the block's fields. You can also pass `itemType`, `url`, `image` and `summary` attributes, and the WordPress book (`author`, `isbn`, `publisher`, `genre`, `copyright`) and software (`os`, `category`) fields. To turn imported markers into Coywolf Review blocks instead, see [Migrating from WordPress](wordpress-import.md). `<Review>` isn't gated by the feature switch, because a theme that uses it has opted in, but it uses the Reviews page's accent and custom CSS. Schema still needs `reviews.schema`. Remove any review JSON-LD the theme prints itself.
88
+
89
+ `reviews: false` leaves the module out.