@coywolf/emdash 0.35.13 → 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/README.md +2 -0
- 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 +11 -2
- package/src/version.ts +1 -1
- package/src/ai/logic.test.ts +0 -187
- package/src/ai/queue.test.ts +0 -97
- package/src/ai/wikidata.test.ts +0 -184
- package/src/backups/dump.test.mjs +0 -287
- package/src/backups/restore.test.mjs +0 -103
- package/src/codeBlocks/render.test.ts +0 -152
- package/src/files/counts.test.ts +0 -114
- package/src/files/format.test.ts +0 -93
- package/src/files/r2-rules.test.ts +0 -30
- package/src/files/sigv4.test.ts +0 -64
- package/src/headings/headings.test.ts +0 -159
- package/src/pageCache/lib.test.mjs +0 -224
- package/src/pageCache/warm.test.mjs +0 -670
- package/src/redirects/slug-core.test.ts +0 -50
- package/src/robots/rep.test.ts +0 -335
- package/src/robots/rules.test.ts +0 -251
- package/src/schema/graph.test.mjs +0 -522
package/README.md
CHANGED
|
@@ -192,6 +192,8 @@ Each module has its own page in [docs](docs/README.md), with its setup, settings
|
|
|
192
192
|
|
|
193
193
|
## Development
|
|
194
194
|
|
|
195
|
+
Releases are tagged, published on GitHub and on npm: see [Releasing](docs/releasing.md).
|
|
196
|
+
|
|
195
197
|
```bash
|
|
196
198
|
npx tsc --noEmit -p . # typecheck
|
|
197
199
|
npm test # unit tests: test/ and src/**/*.test.ts (Node 22.15+; runs the TypeScript sources directly)
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Coywolf Pack documentation
|
|
2
|
+
|
|
3
|
+
Coywolf Pack is an [EmDash](https://emdashcms.com) plugin that enhances existing features and adds features not available in EmDash. It helps you manage your site like a pro and ensure it's absurdly fast for humans and bots.
|
|
4
|
+
|
|
5
|
+
- **Absurdly fast for humans and bots:** cached pages answer in 60–90 ms, cache warming keeps pages cached for visitors and crawlers alike, and images come straight from Cloudflare's edge.
|
|
6
|
+
- **Enhances what EmDash already does:** redirects, search, robots.txt, schema, images and RSS feeds go well beyond EmDash's built-in versions and starter templates.
|
|
7
|
+
- **Adds what EmDash doesn't have:** backups and restore, link checking, videos, reviews, AI enrichment and more.
|
|
8
|
+
- **Manage your site like a pro:** turn each feature on or off, see your Cloudflare usage and estimated bill, and finish a move from WordPress. Everything starts off, so installing or updating changes nothing until you're ready.
|
|
9
|
+
- **Free and open source** (MIT).
|
|
10
|
+
|
|
11
|
+
Start with [Getting started](../README.md#getting-started) to install the pack and turn features on.
|
|
12
|
+
|
|
13
|
+
## Site-wide
|
|
14
|
+
|
|
15
|
+
- [Performance](performance.md): Workers Cache, clearing after deploys and edits, cache warming, Cloudflare usage
|
|
16
|
+
- [Content URLs](content-urls.md): telling the pack how your theme routes entries (category paths, nested pages)
|
|
17
|
+
- [Internals](internals.md): how the pack keeps D1 reads to about one round trip per uncached page
|
|
18
|
+
|
|
19
|
+
## Modules
|
|
20
|
+
|
|
21
|
+
- [Clean Image URLs](images.md)
|
|
22
|
+
- [Backups](backups.md)
|
|
23
|
+
- [Redirects](redirects.md)
|
|
24
|
+
- [Schema & Social](schema.md)
|
|
25
|
+
- [Discovery](discovery.md): IndexNow, RSS feed, news sitemap, llms.txt
|
|
26
|
+
- [Robots.txt Rules](robots.md)
|
|
27
|
+
- [Search](search.md)
|
|
28
|
+
- [Link Manager](links.md)
|
|
29
|
+
- [Headings & TOC](headings.md)
|
|
30
|
+
- [Breadcrumb Nav](breadcrumbs.md)
|
|
31
|
+
- [Code Blocks](code-blocks.md)
|
|
32
|
+
- [File Downloads](files.md)
|
|
33
|
+
- [Private form uploads](form-uploads.md)
|
|
34
|
+
- [Videos](videos.md)
|
|
35
|
+
- [Reviews](reviews.md)
|
|
36
|
+
- [Custom Blocks](custom-blocks.md)
|
|
37
|
+
- [AI Enrichment](ai.md)
|
|
38
|
+
|
|
39
|
+
## Moving to EmDash
|
|
40
|
+
|
|
41
|
+
- [Migrating from WordPress](wordpress-import.md)
|
|
42
|
+
|
|
43
|
+
## Maintaining
|
|
44
|
+
|
|
45
|
+
- [Releasing](releasing.md): tags, GitHub releases and npm publishing
|
package/docs/ai.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# AI Enrichment
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
Ported from Coywolf SEO's AI features, with the same prompts and validation:
|
|
6
|
+
|
|
7
|
+
- **Entities for schema** (`ai.entities`). The model lists the people, organizations, places, and things a page is about, without identifiers. Each name is looked up on Wikidata (`wbsearchentities`), and only items whose label or alias is that name (ignoring case, accents, and punctuation) count. When several match, the model may only pick one of them. Each chosen item's `instance of` (P31) claims are then checked (one Wikidata Query Service request per entry for all its items): disambiguation pages are dropped, a Person must be human, an Organization or Place can't be a creative work (the album instead of the band), and an Organization can't be a place. The JSON-LD `name` is Wikidata's label. The result is stored per entry as Schema.org `about` (main subjects) and `mentions`, each with `sameAs` links to Wikidata, Wikipedia, and the official website (P856) when there is one. Coywolf's Schema module reads them with `getEntryEntities(ctx | db, collection, id)` from `src/ai/entities.ts`.
|
|
8
|
+
- **Output entities on their own** (`ai.entitiesStandalone`). Adds the entities to the page's JSON-LD as a `WebPage` node (same `@id` as EmDash's `mainEntityOfPage`). Only for sites that don't use Coywolf Schema.
|
|
9
|
+
- **Meta descriptions** (`ai.descriptions`). Writes a description (under 155 characters, no clickbait) for published entries in SEO-enabled collections that don't have one. By default they're suggestions you edit and apply on the AI page; or have empty descriptions filled automatically. Either way it writes only EmDash's SEO panel field and never replaces a description someone wrote unless you choose **Replace**.
|
|
10
|
+
- **Image text** (`ai.imageText`). Writes alt text (and, if you turn it on, captions) for images on upload and in bulk, with accessibility-first prompts. Alt text and captions people wrote are kept unless you turn on overwrite. EmDash media has no title or description fields, so titles are shown on the AI page for reference only.
|
|
11
|
+
|
|
12
|
+
All four are off by default, under the **AI Enrichment** switch on the Coywolf Pack page.
|
|
13
|
+
|
|
14
|
+
## How it runs
|
|
15
|
+
|
|
16
|
+
Saving, publishing, or uploading never waits on AI: hooks only add the entry or image to a queue (after a short wait, so a burst of saves becomes one job, and only when the entry's text actually changed). A scheduled job works through the queue every two minutes (or with each cron tick, if the site's cron runs less often), a few items at a time (**Items per scheduled run**; raise it on a site with a slower cron), and stops for the day at **Max model calls per day** (default 200; an entry costs up to three calls, an image one). Each run also stays under 40 outbound requests (model calls plus Wikidata lookups), so it fits the Workers Free plan's subrequest limit; entries and images take turns, so a backlog of one doesn't hold up the other. Failed items are retried after 5 and 20 minutes, then marked as errors; a retry reuses the model output it already paid for. Deleting an entry removes its analysis. **Run bulk** on the AI page queues every published entry (skipping unchanged ones) or every image without alt text. The AI page also has a connection test, the queue status, suggestions to review, the analyzed entities (with **Export CSV**: one row per entity with its Wikidata, Wikipedia and website links, plus each entry's AI description), and a 30-day usage log with token counts.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
Pick a provider on **Plugins → AI Enrichment → Settings**:
|
|
21
|
+
|
|
22
|
+
- **Cloudflare Workers AI** (the default when the binding exists). Add the binding to `wrangler.jsonc`:
|
|
23
|
+
|
|
24
|
+
```jsonc
|
|
25
|
+
"ai": { "binding": "AI" }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The default model is `@cf/meta/llama-4-scout-17b-16e-instruct` for both text and images. A different binding name goes in `coywolfPlugin({ ai: { binding: "MY_AI" } })`.
|
|
29
|
+
- **Anthropic, OpenAI, or Google Gemini** with your own API key (stored encrypted, so the site needs `EMDASH_ENCRYPTION_KEY`). Defaults (checked October 2026): `claude-sonnet-5-5`, `gpt-6-luna`, `gemini-3.8-flash`. To use another model, such as a cheaper one or a newer one after a default is retired, enter its id in **Text model** and **Image model** on the Settings tab; leave them blank to follow the defaults.
|
|
30
|
+
|
|
31
|
+
Images are sent to the model at up to 3.5 MB. With a Cloudflare Images binding (`"images": { "binding": "IMAGES" }`, the same one EmDash uses for resizing) larger images are downscaled to 1568 pixels first; without one they're skipped. SVGs are skipped.
|
|
32
|
+
|
|
33
|
+
Set `ai: false` in `coywolfPlugin()` to leave the module out entirely. The module declares the `network:request` capability (for `api.anthropic.com`, `api.openai.com`, `generativelanguage.googleapis.com`, `www.wikidata.org`, and `query.wikidata.org`), plus content read/write (SEO panel only), schema read, and media read, bytes, and metadata write.
|
|
34
|
+
|
|
35
|
+
## Notes
|
|
36
|
+
|
|
37
|
+
- Only published entries are analyzed. Change the model or switch features and entries are re-analyzed the next time they're saved (or with **Re-analyze unchanged entries**).
|
|
38
|
+
- The daily limit is counted in UTC days. Each call's token usage is in the usage log; costs depend on your provider.
|
|
39
|
+
- Wikidata lookups are cached per Worker isolate and made one at a time, with `maxlag=5`. If Wikidata doesn't answer, the item is retried rather than saved unverified; if it asks to slow down (HTTP 429 or maxlag), every entity job waits for its `Retry-After` without counting as a failed attempt (after 10 waits in a row, the next counts as one). When an item lists several official websites, the preferred-rank one is kept, then https over http, then the first alphabetically.
|
package/docs/backups.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Backups
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
- **Database**: a restorable SQL dump of the site's D1 database: users, passkeys, settings, redirects, menus, plugin data, and content. Search indexes rebuild automatically on restore.
|
|
6
|
+
- **Media**: a mirror of the R2 media bucket. Replaced or deleted files are kept under a dated folder until retention expires.
|
|
7
|
+
- **Private form uploads**: when the site has a `FORM_UPLOADS` binding (**Private form uploads**), a mirror of that bucket too, under `uploads/`. Set `backups.uploads` to another binding name, or `false` to leave uploads out. Keep the backup bucket private: these are files people sent through forms.
|
|
8
|
+
- **Large rows**: D1 rejects statements over 100 KB, but posts can be bigger. Rows that don't fit in one INSERT are written with their long values in pieces, and the restore puts them back together exactly. Plugin caches are left out (they rebuild on demand). Rows are read up to 500 at a time, fewer when they're large: each read is sized from the bytes the previous one returned (about 8 MB), and a read D1 refuses as too large (it rejects results over 50 MB from November 4, 2026) is asked again for fewer rows.
|
|
9
|
+
- **Admin**: **Back up now**, **Download** any backup (see below), and a dashboard widget that warns when backups stop. Settings (**Schedule and retention** on the Backups page): daily scheduled backup (default off), retention (default 30 days), staleness warning (default 36 hours).
|
|
10
|
+
- **Restore** (optional, see below): **Rewind to this backup** (D1 Time Travel, with **Undo rewind**), **Restore to a new database** (import plus per-table row-count check), and **Restore missing media** (media first, then private form uploads, a batch of 200 files per request until done).
|
|
11
|
+
|
|
12
|
+
Theme code isn't included: it lives in your Git repository.
|
|
13
|
+
|
|
14
|
+
## Download a backup
|
|
15
|
+
|
|
16
|
+
Each backup's menu has **Download (.sql.gz)**: the gzipped SQL dump of the whole database, saved as `<name>-<stamp>.sql.gz`. It restores into any D1 database (see **Manual restore**), so you always have your own copy. The page asks the `backups/download-link` route (admins only) for a link that works for ten minutes, and the `coywolfPack()` middleware streams the file from R2 at `/_coywolf-pack/backup/<token>/<file name>`, so dumps of any size download without the Worker holding them in memory. Links are random, unlisted tokens stored in the backup bucket (`downloads/`), sent with `Cache-Control: private, no-store`; expired ones are deleted when the next link is made. Media isn't part of the download: the media mirror stays in the backup bucket (copy it with `rclone` or the R2 dashboard).
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
1. Create a backup bucket and bind it as `BACKUPS` in `wrangler.jsonc`:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx wrangler r2 bucket create coywolf-backups
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```jsonc
|
|
27
|
+
"r2_buckets": [
|
|
28
|
+
{ "binding": "MEDIA", "bucket_name": "coywolf-media" },
|
|
29
|
+
{ "binding": "BACKUPS", "bucket_name": "coywolf-backups" }
|
|
30
|
+
]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
2. Options: `database` (default `"DB"`), `media` (`"MEDIA"`), `backups` (`"BACKUPS"`), `name` (dump file prefix, default `"database"`).
|
|
34
|
+
3. Downloads need the `coywolfPack()` middleware (see the Redirects setup).
|
|
35
|
+
|
|
36
|
+
## Enable restore
|
|
37
|
+
|
|
38
|
+
Restores use the Cloudflare API, because a Worker can't create databases or time-travel through its D1 binding.
|
|
39
|
+
|
|
40
|
+
1. Create an API token with one permission, **Account → D1 → Edit**.
|
|
41
|
+
2. Store it as a Worker secret. It isn't a plugin setting, because a rewind rolls plugin settings back too:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx wrangler secret put BACKUPS_API_TOKEN
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. Add the IDs and deploy:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
coywolfPlugin({
|
|
51
|
+
backups: { name: "coywolf", restore: { accountId: "<account id>", databaseId: "<D1 database id>" } },
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Rewinds save an undo point to the backup bucket first; undo steps back one rewind at a time.
|
|
56
|
+
|
|
57
|
+
## Backup layout
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
d1/<stamp>/<name>.sql.gz database dump
|
|
61
|
+
d1/<stamp>/manifest.json stamp, size, SHA-256, source, row counts, Time Travel bookmark
|
|
62
|
+
media/current/<key> mirror of the media bucket
|
|
63
|
+
media/changed/<stamp>/<key> media replaced or deleted at that backup
|
|
64
|
+
uploads/current/<key> mirror of the private form uploads bucket
|
|
65
|
+
uploads/changed/<stamp>/<key> uploads replaced or deleted at that backup
|
|
66
|
+
restore/undo/<time>.json undo points for rewinds
|
|
67
|
+
downloads/<token>.json download links (ten minutes each)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
External jobs (a nightly GitHub Action, for example) can write to the same layout, and the admin lists those backups too.
|
|
71
|
+
|
|
72
|
+
## Manual restore
|
|
73
|
+
|
|
74
|
+
Restore a downloaded backup (or one copied from the bucket) into a **new, empty** D1 database and switch the `DB` binding to it. Never import over the live database:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx wrangler d1 create coywolf-restore
|
|
78
|
+
gunzip -c coywolf.sql.gz > restore.sql
|
|
79
|
+
npx wrangler d1 execute coywolf-restore --remote --file restore.sql
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Keep `EMDASH_ENCRYPTION_KEY` in a password manager. It isn't in backups, and encrypted plugin settings can't be read without it.
|
|
83
|
+
|
|
84
|
+
## Notes
|
|
85
|
+
|
|
86
|
+
- `wrangler d1 export` refuses databases with FTS5 tables (EmDash search). The dump writes tables, then search tables and their triggers, then rows with parent tables before child tables (D1 imports large files in batches, so deferred foreign keys aren't enough), then indexes and the remaining triggers.
|
|
87
|
+
- Backups run in one Worker request, so they suit small and medium sites. The dump is streamed: rows are read 500 at a time (paged by rowid) and gzipped straight into the backup bucket (as a multipart upload in 5 MB parts once it's bigger than one part), so the Worker never holds the whole dump in memory. The database is saved before the media mirror runs. The mirror copies at most 300 changed files per run, 8 at a time; the rest follow on the next run.
|
|
88
|
+
- Integers larger than 2^53 lose precision (D1 returns JavaScript numbers). EmDash stores IDs as text.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Breadcrumb Nav
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
An accessible breadcrumb trail (`<nav aria-label="Breadcrumb">` around an `<ol>`, `aria-current="page"` on the current page, separators in CSS that screen readers skip), as a component for theme layouts and as a **Breadcrumbs** block for content. Off until you turn it on under **Plugins → Coywolf Pack**:
|
|
6
|
+
|
|
7
|
+
| Feature | Default | What it does |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `breadcrumbs` | off | Main switch: the `Breadcrumbs` component, the **Breadcrumbs** block, and the **Breadcrumb Nav** page |
|
|
10
|
+
|
|
11
|
+
Until 0.6.0 this was the `headings.breadcrumbs` sub-feature of Headings & TOC. Sites keep their state: while `breadcrumbs` has never been saved, it follows the stored `headings.breadcrumbs` switch (on only if Headings & TOC was on too), and the settings fall back to the breadcrumb defaults stored with Headings & TOC until the Breadcrumb Nav page saves its own. The block type (`coywolf-breadcrumbs`), the `Breadcrumbs` export and the stored block data are unchanged, and Breadcrumb Nav no longer depends on Headings & TOC being on.
|
|
12
|
+
|
|
13
|
+
Site defaults live on **Breadcrumb Nav** (with a live preview): separator (`/`, `›`, `»`, `•`, `→`, `>`, or a custom string of up to 8 characters), home label, and whether the trail starts with the home page and ends with the current page. The page also has a step-by-step guide for adding the component to a theme, with copyable code.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Add the component to a layout, passing the same page context you give `EmDashHead`:
|
|
18
|
+
|
|
19
|
+
```astro
|
|
20
|
+
---
|
|
21
|
+
import { EmDashHead } from "emdash/ui";
|
|
22
|
+
import { createPublicPageContext } from "emdash/page";
|
|
23
|
+
import { Breadcrumbs } from "@coywolf/emdash/astro";
|
|
24
|
+
|
|
25
|
+
const page = createPublicPageContext({ Astro, kind: "custom", title, pageTitle: title, breadcrumbs });
|
|
26
|
+
---
|
|
27
|
+
<EmDashHead page={page} />
|
|
28
|
+
…
|
|
29
|
+
<Breadcrumbs page={page} />
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Give each page its real trail with `breadcrumbs` (root first, current page last); `[]` hides the trail on that page, and leaving it out derives one from the URL:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
breadcrumbs: [
|
|
36
|
+
{ name: "Home", url: "/" },
|
|
37
|
+
{ name: category.label, url: `/${category.slug}/` },
|
|
38
|
+
{ name: entry.data.title, url: Astro.url.pathname },
|
|
39
|
+
],
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Props: `page` (the `PublicPageContext` you pass to `EmDashHead`), `items` (`{ name, url }[]`), `title`, `separator` (`slash`, `chevron`, `guillemet`, `bullet`, `arrow`, `gt`, or any short string), `homeLabel`, `showHome`, `showCurrent`, `class`, `label` (the nav's `aria-label`, default "Breadcrumb"). Props override the site defaults.
|
|
43
|
+
|
|
44
|
+
The trail comes from, in order: `items`, `page.breadcrumbs`, the trail the theme gave `EmDashHead` for the same URL (picked up by the module's `page:metadata` hook, so the Breadcrumbs block uses it too), or the URL path (home, one crumb per path segment with a readable name, then the page title). Give themes with archives or nested content a real trail through `page.breadcrumbs`: derived ancestor links point at whatever the path segments are, which may not be pages.
|
|
45
|
+
|
|
46
|
+
With Schema & Social's `schema.breadcrumbs` on, the `BreadcrumbList` is built from the same `page.breadcrumbs`, so one trail feeds both the visible nav and the structured data. The schema uses the trail as given (include Home and the current page); the nav applies the start-with-home and end-with-current settings.
|
|
47
|
+
|
|
48
|
+
Without theme changes, add a **Breadcrumbs** block to an entry instead. Each block can override the separator, home label, and whether the current page shows; the entry's title is saved with the block for the last crumb.
|
|
49
|
+
|
|
50
|
+
## Styling
|
|
51
|
+
|
|
52
|
+
Classes: `.cw-breadcrumbs` (the `<nav>`, plus `.cw-breadcrumbs--sep-<preset>`), `.cw-breadcrumbs__list` (the `<ol>`, a wrapping flex row), `.cw-breadcrumbs__item` (each `<li>`; the separator is its `::before`), `.cw-breadcrumbs__current` (the current page). The separator is the `--cw-bc-sep` custom property (a CSS string). Links use the theme's colors. The component's rules are single-class, so a selector like `.site-header .cw-breadcrumbs a { … }` overrides them regardless of stylesheet order.
|
|
53
|
+
|
|
54
|
+
## Notes
|
|
55
|
+
|
|
56
|
+
- Server-rendered with no script; the small global stylesheet (`cw-` classes only) is bundled on pages that import the component, even with the feature off.
|
|
57
|
+
- A trail of one crumb (just Home, or just the page) isn't shown.
|
|
58
|
+
- The Breadcrumbs block picks up the theme's trail only when the theme renders `<EmDashHead page={page} />` with the page's real `url`; it's matched by path and query string, so locales and variants don't mix.
|
|
59
|
+
- The module's `content:beforeSave` hook only writes `_title` on Breadcrumbs blocks (it declares the `content:write` capability EmDash requires for the hook).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Code Blocks
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
A port of Coywolf Code Block Enhancer. It replaces the site renderer for EmDash's built-in code block (the editor's language picker is unchanged) with one that highlights code **on the server** using highlight.js grammars (via lowlight, which EmDash already ships). Visitors download no highlighting script.
|
|
6
|
+
|
|
7
|
+
- **Themes** (**Plugins → Code Blocks**, with a live preview): Coywolf Auto (light/dark by system), Coywolf Always light, Coywolf Always dark, four light/dark pairs that follow the system setting (GitHub, Atom One, A11y, Tokyo Night), and 19 popular highlight.js themes (GitHub, GitHub Dark, Monokai, Nord, Dracula, Solarized, Visual Studio, Xcode, Night Owl and more). The theme is the `codeBlocksTheme` setting. Every theme meets WCAG AA contrast (4.5:1): highlight.js colors below that against their theme's background (comments in most themes, Rosé Pine's body text) are darkened or lightened, keeping their hue, and line numbers and the language label use muted colors that still pass (the faded text they always had, raised only where that fell under 4.5:1).
|
|
8
|
+
- **Language label**, **Copy button** and **Line numbers** are sub-features. The copy button follows the WordPress plugin's accessible pattern: a labeled button, a polite live region that announces "Copied to clipboard", a two-second confirmation, and no animation for visitors who prefer reduced motion. Line numbers are drawn with CSS, so they're never selected or copied. The header shows the language on the left (file names aren't shown) with the copy button on the right.
|
|
9
|
+
- **Page weight**: the layout CSS (about 2 KB), the active theme's CSS (1–4 KB) and, with the copy button on, one small inline script (under 1 KB) are inlined once per page by the first code block. Pages without code blocks get nothing, and no external requests are made.
|
|
10
|
+
|
|
11
|
+
Feature switches: `codeBlocks` (main), `codeBlocks.label`, `codeBlocks.copy`, `codeBlocks.lineNumbers`, all off by default. While `codeBlocks` is off, code blocks render exactly as EmDash's built-in renderer does.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
Nothing beyond the plugin itself: the block renderer is registered through the plugin's `componentsEntry`, so it applies wherever the site renders Portable Text with EmDash's `<PortableText>`. It reads the theme and switches from the site's D1 database (`DB`), cached for five minutes per Worker isolate (a save on the Code Blocks page applies at once in that isolate; see [Database reads](internals.md)).
|
|
16
|
+
|
|
17
|
+
## Notes
|
|
18
|
+
|
|
19
|
+
- Languages: Prism names used by Code Block Enhancer for WordPress work too (`markup` is HTML, `svg` and `mathml` are XML). EmDash's editor list (Astro, Svelte and Vue are highlighted as HTML, MDX as Markdown, TOML as INI) plus highlight.js's common grammars. Unknown languages, blocks over 30,000 characters and blocks with any line over 2,000 characters are shown as plain text (highlight.js slows sharply on long lines). Rendered blocks are cached in memory (200 per Worker isolate), so repeat page views don't re-highlight.
|
|
20
|
+
- Theme CSS is generated from highlight.js's own stylesheets (BSD-3-Clause, each theme's original credits kept) by `node scripts/gen-code-themes.mjs`.
|
|
21
|
+
- Tests: `node --import ./test/ts-resolve.mjs --test src/codeBlocks/render.test.ts`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Content URLs
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
Several modules link to entries: llms.txt and the news sitemap, IndexNow, per-entry Markdown, the video sitemap, Schema previews, search results, and removed-content redirects. By default they resolve an entry's URL the way EmDash does, from the collection's URL pattern (`{slug}`, `{id}`, and date tokens such as `{year}`). If your theme routes a collection differently, tell the pack with `urls`:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
// coywolf.com serves posts under their category path, as WordPress did (/news/seo/<slug>/)
|
|
9
|
+
coywolfPlugin({
|
|
10
|
+
urls: { posts: "/{termpath:category|uncategorized}/{slug}/" },
|
|
11
|
+
});
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Patterns take EmDash's tokens plus taxonomy tokens:
|
|
15
|
+
|
|
16
|
+
- `{term:<taxonomy>}` is the slug of the entry's first term in that taxonomy, in the order EmDash's `getTermsForEntries` returns them (by label).
|
|
17
|
+
- `{term:<taxonomy>|<fallback>}` uses `<fallback>` when the entry has no term. Without a fallback, an entry with no term has no URL.
|
|
18
|
+
- `{category}` is shorthand for `{term:category|uncategorized}`.
|
|
19
|
+
- `{termpath:<taxonomy>}` (and `{termpath:<taxonomy>|<fallback>}`) is the same term with its parent terms in front, root first, like WordPress's `%category%` permalink: `news/seo` for SEO under News, `guides/method-seo/structure` three levels down. A term with no parent is just its slug. Parents come from EmDash's categories (the parent set under **Taxonomies**), read once per request or build. Chains stop at a loop or after 32 levels.
|
|
20
|
+
- `{pagepath}` is the entry's slug with its parent pages' slugs in front (`apps/coywolf-seo`). EmDash entries have no parent field, so the parents come from the `pageParents` option (slug → parent slug). An entry with no parent is just its slug.
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// coywolf.com: WordPress's /%category%/%postname%/ with the full category path, and nested pages
|
|
24
|
+
coywolfPlugin({
|
|
25
|
+
urls: {
|
|
26
|
+
posts: "/{termpath:category|uncategorized}/{slug}/",
|
|
27
|
+
pages: "/{pagepath}/",
|
|
28
|
+
},
|
|
29
|
+
pageParents: { "coywolf-seo": "apps", "coywolf-files": "apps" },
|
|
30
|
+
// Optional: parents for categories that have none in EmDash yet (e.g. right after a WordPress import).
|
|
31
|
+
termParents: { category: { seo: "news", "method-seo": "guides", structure: "method-seo" } },
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`termParents` (taxonomy → term slug → parent slug) is a fallback: it's used only for terms that have no parent in EmDash, so once the parents are restored (see [Migrating from WordPress](wordpress-import.md), "Category and page parents") it can go.
|
|
36
|
+
|
|
37
|
+
Collections without an override keep EmDash's resolution. The pack also maps paths back to entries (for example, the Markdown source at `/news/seo/some-post/index.html.md` resolves only when `news/seo` is the post's primary category path), so a wrong category doesn't match. With `{termpath:…}` the whole path must match: `/news/seo/some-post/` resolves, `/seo/some-post/` doesn't (the theme should redirect it, as WordPress did). With `{pagepath}`, a page under a parent resolves only at its full path.
|
|
38
|
+
|
|
39
|
+
Trailing slashes follow Astro's `trailingSlash` setting. With the default (`"ignore"`), an override keeps the pattern's own trailing slash. Set `trailingSlash: "always" | "never"` on `coywolfPlugin()` to force one for every entry URL the pack builds. Override URLs get no locale prefix.
|
|
40
|
+
|
|
41
|
+
The options are read when the Worker starts (EmDash creates the plugin then), so the middleware and Astro components use them too. Sites can resolve URLs the same way with `entryUrl`, `entryUrls`, and `matchEntryPath` from `@coywolf/emdash/astro`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Custom Blocks
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
Six editor blocks (slash menu → Content), each with its own switch, named after Coywolf's WordPress plugin, Custom Blocks. They're server-rendered plain HTML with no script, accessible (landmarks with names, native disclosure widgets, real quotations), and share one small stylesheet (under 5 KB) inlined once per page before the first block. They're rendered by the pack's components rather than stored as HTML, so EmDash's HTML sanitizer doesn't strip `<details>`, `<summary>`, `<aside>` or `<figure>`.
|
|
6
|
+
|
|
7
|
+
| Feature | Default | Block | Fields |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| `customBlocks` | off | (module switch; the Custom Blocks page) | |
|
|
10
|
+
| `customBlocks.note` | off | **Note**: a callout in an `<aside>` labeled by its title | Kind (Note, Editor's note, Tip, Warning), title (empty = the kind's name), hide title, title style (bold text or an H2–H4), text |
|
|
11
|
+
| `customBlocks.details` | off | **Details**: native `<details>`/`<summary>` | Style (Details, or Transcript: the hidden text sits in a tinted panel), summary (default "Details" / "Read the transcript"), hidden text, open when the page loads |
|
|
12
|
+
| `customBlocks.disclosure` | off | **Affiliate disclosure**: small, muted text in an `<aside>` labeled "Affiliate disclosure" | Disclosure (Affiliate links or Amazon Associates), wording for this page (optional; empty uses the site's) |
|
|
13
|
+
| `customBlocks.quote` | off | **Quote**: `<figure><blockquote cite>…</blockquote><figcaption><cite>…</cite></figcaption></figure>` | Quote, who said it, source URL (the `cite` attribute) |
|
|
14
|
+
| `customBlocks.testimonial` | off | **Testimonial**: `<figure><blockquote>…</blockquote><figcaption>` photo, name, title `</figcaption></figure>`; the quote sits in a speech bubble with the person below it | What they said, name, title, photo (media library), link for the name (e.g. a social profile), link for the title (e.g. their company) |
|
|
15
|
+
| `customBlocks.podcast` | off | **Podcast links**: a `<section>` labeled by its heading, with a list of links named by service (Apple Podcasts, Spotify, YouTube, Amazon Music, Overcast, Pocket Casts, RSS feed), each with a small line icon | Links (the site's podcast from the Custom Blocks page, or only this block's), heading (empty = the site's), a link per service |
|
|
16
|
+
|
|
17
|
+
**Text fields** are plain text: a blank line starts a new paragraph, a single line break is a line break, `[link text](https://…)` is a link and `**text**` is bold. Simple HTML works too, so imported WordPress content keeps its exact markup: links (`href`, `title`, `rel` limited to `nofollow`, `sponsored`, `ugc` and the like), bold, italics, `<code>`, `<abbr title>`, `<q cite>`, `<mark>`, `<sub>`/`<sup>`, lists, paragraphs, H2–H6, `<figure>` and `<img>`, and text styling in `style` (color, font size and weight, …). Everything else is dropped (its text stays), links are limited to http(s), mailto, tel and site-relative URLs, and tags left open are closed, so a block can't break the page around it. Titles, summaries and citations are one line (no paragraphs or block tags). A testimonial's name and title are plain text.
|
|
18
|
+
|
|
19
|
+
**Testimonials** have no schema: Google doesn't show review rich results for reviews a site collects and shows about itself, and a testimonial has no rating. The photo's alt text is empty because the name right next to it says who it is.
|
|
20
|
+
|
|
21
|
+
**Custom Blocks page** (**Plugins → Coywolf Pack → Custom Blocks**): the site's disclosure wording for affiliate links and for Amazon Associates (Amazon requires "As an Amazon Associate I earn from qualifying purchases."), an optional disclosure page linked after it ("Learn more" by default); the podcast's links (one per service; empty ones are left out), heading ("Subscribe to the podcast" by default), heading style (H2–H4 or bold text) and whether to show icons; and live previews of every block. A Podcast links block with no links shows nothing.
|
|
22
|
+
|
|
23
|
+
**Styling**: colors are mixed from the theme's text color (`currentColor`), so the blocks follow light and dark themes; notes add a hue per kind, a little stronger in dark mode. Override with the theme's CSS: `--cw-note-accent` on `.cw-note` (or `.cw-note--tip`, …), and the classes `.cw-note`, `__title`, `__body`; `.cw-details`, `--transcript`, `__summary`, `__body`; `.cw-disclosure`, `__text`, `__link`; `.cw-quote`, `__text`, `__caption`; `.cw-testimonial`, `__quote`, `__person`, `__photo`, `__name`, `__title`; `.cw-podcast`, `__title`, `__links`, `__link` (and `--apple`, `--spotify`, … per service), `__icon`.
|
|
24
|
+
|
|
25
|
+
**Renamed in 0.12.0**: this module was called Content Blocks. Its page moved from `/content-blocks` to `/custom-blocks` (the old address still opens it), and its feature ids from `contentBlocks*` to `customBlocks*`; saved switches carry over, and the block types (`coywolf-note`, `coywolf-details`, `coywolf-disclosure`, `coywolf-quote`) and the saved disclosure wording are unchanged.
|
|
26
|
+
|
|
27
|
+
`customBlocks: false` leaves the module out (`contentBlocks: false` still works).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Discovery
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
Help search engines, feed readers and AI agents find your content. Ported from Coywolf SEO for WordPress (the RSS feed is new). Everything is off until you turn it on under **Plugins → Coywolf Pack**: the **Discovery** switch, then any of its four parts (here or with the switch next to each part's title on the Discovery page). Settings and status are under **Plugins → Coywolf Pack → Discovery**.
|
|
6
|
+
|
|
7
|
+
| Feature switch | Default | What it does |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `discovery` | off | Main switch for the module |
|
|
10
|
+
| `discovery.indexnow` | off | IndexNow submissions and the key file |
|
|
11
|
+
| `discovery.newsSitemap` | off | `/news-sitemap.xml` |
|
|
12
|
+
| `discovery.feed` | off | RSS feed at `/feed/`, category feeds, and the `<link rel="alternate" type="application/rss+xml">` head tag |
|
|
13
|
+
| `discovery.llms` | off | `/llms.txt`, Markdown versions of entries, and the `<link rel="alternate" type="text/markdown">` head tag |
|
|
14
|
+
|
|
15
|
+
- **IndexNow**: when an entry is published, updated (in collections without draft revisions), unpublished, or deleted, its public URL goes to `api.indexnow.org` (or `www.bing.com`), which shares it with Bing, Yandex, Seznam, Naver, and the other participating engines. Changes made within 3 seconds of each other go out as one request, after the response is sent. A changed slug submits the old URL too. Entries set to noindex aren't submitted. The key is generated the first time it's needed (or when you open the Discovery page with IndexNow on) and served at `/<key>.txt`; **New key** replaces it. The page shows the last 25 submissions and has **Submit home page** for checking the setup. While the site is in EmDash's staging mode (**Settings → General → Site Status**, EmDash 1.5+), hidden from search engines, nothing is submitted, **Submit home page** is refused, and the Discovery page says so; submissions start when the site goes live.
|
|
16
|
+
- **News sitemap**: entries published in the last 48 hours (the window Google News uses), newest first, at most 1,000, with publication name and language (defaults: site title and locale). Choose the collections (default: `posts`). Also served at `/coywolf-news-sitemap.xml`, Coywolf SEO's URL, so a sitemap submitted from WordPress keeps working. Submit it in Google Search Console; to list it in robots.txt, add `Sitemap: https://coywolf.com/news-sitemap.xml` to the custom robots.txt under **Settings → SEO**. The module doesn't change robots.txt.
|
|
17
|
+
- **RSS feed**: RSS 2.0 at `/feed/` (WordPress's feed URL) with the newest entries of the chosen collections (default `posts`, 20 entries, up to 100), each with its title, link, author (the first byline), publish date, category (its first term in the taxonomy its URL uses, or `category`), and summary (the `excerpt` field or SEO description). The channel has the site title, tagline, locale, and the site logo (or favicon) when it's a PNG, JPEG or GIF. Every page gets `<link rel="alternate" type="application/rss+xml" href="https://coywolf.com/feed/">` in the head, so feed readers, apps and bots find it. **Category feeds** (on by default): when a collection's URL override is a term then the slug (`urls: { posts: "/{termpath:category|uncategorized}/{slug}/" }`, or `{term:…}` or `{category}`, with an optional fixed prefix), each term's archive URL + `feed/` is that term's feed, including its child terms' entries (`/news/feed/`, `/news/seo/feed/`), titled "<term> Archives - <site>" as WordPress titles them. A path that isn't a feed (no such term) goes on to the site. Entries set to noindex aren't listed. Feeds are built when requested and kept in the edge cache (Workers Cache, with Astro route caching on) for up to an hour, tagged with their collections and taxonomy: EmDash clears those tags on every publish, live edit, removal and term change, and saving the Discovery settings clears all feeds, so a change shows up on the next request. Readers may keep their own copy for up to 15 minutes (`Cache-Control: public, max-age=900`). Paths that aren't feeds are remembered in each isolate's memory for a minute. `/feed` without the slash is left to the site (which redirects it to `/feed/`).
|
|
18
|
+
- **llms.txt** ([llmstxt.org](https://llmstxt.org/)): the site name, a summary (default: the site tagline), an optional introduction, then one list per collection (newest first, 100 per collection) and an **Optional** list for the rest, up to 500 entries (configurable up to 5,000). Each link points at the entry's Markdown version, or at the page itself when Markdown is off. Excerpts (`excerpt` field or SEO description) become link notes. A static `public/llms.txt` takes precedence, because Cloudflare serves static assets before the Worker runs.
|
|
19
|
+
- **Markdown versions**: each entry at its URL + `index.html.md` (`/blog/post/` → `/blog/post/index.html.md`, Coywolf SEO's convention). The Portable Text body becomes Markdown (headings, paragraphs, lists, links, images with captions, code, block quotes, tables, and horizontal rules; other blocks are skipped) under YAML frontmatter with the title, URL, dates, sources, and an optional license. There's no `Accept: text/markdown` negotiation on the entry's own URL: Cloudflare's cache key ignores `Accept`, so one such request could put Markdown into the cached HTML page. Responses carry `X-Markdown-Tokens`, an estimate at about 4 characters per token. Entry pages get `<link rel="alternate" type="text/markdown">` in the head.
|
|
20
|
+
|
|
21
|
+
Only published, non-noindex entries in routable collections are included. URLs come from EmDash's own routing (each collection's URL pattern, date tokens, locale prefix, and trailing-slash setting), so they match the native sitemap.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
Nothing to configure beyond the feature switches. The module's site URLs are served by the pack middleware (see Redirects → Setup), so add `coywolfPack()` from `@coywolf/emdash/middleware` to `src/middleware.ts` if you haven't. The plugin asks for `content:read`, `schema:read`, `bylines:read` (feed authors), `network:request` (hosts `api.indexnow.org` and `www.bing.com`), and `hooks.page-fragments:register`. A theme that has its own feed route or head link can drop them: the pack's middleware answers `/feed/` first, and the head link would be listed twice.
|
|
26
|
+
|
|
27
|
+
## Caching and limits
|
|
28
|
+
|
|
29
|
+
- llms.txt and the news sitemap are stored in plugin storage and visitors are always served the stored copy. Content changes (publish, unpublish, delete, or a live edit) and settings saves rebuild them in the background after the response, debounced by 5 seconds; a copy older than its limit (5 minutes for the news sitemap, whose window moves; 24 hours for llms.txt) is served once more while a background rebuild runs. Only the very first request, before anything is stored, builds inline. **Rebuild now** on the Discovery page rebuilds both immediately. Each Worker isolate also keeps responses for a minute (up to about 2 MB), and CDNs may keep `llms.txt` and `.md` files for an hour (`Cache-Control: public, max-age=3600`; 5 minutes for the news sitemap).
|
|
30
|
+
- Markdown URLs resolve through EmDash's routing (`resolveEmDashPath`, or `/{collection}/{slug}` for collections without a URL pattern), then are checked against the entry's public URL. Entries behind a locale prefix or a custom route don't get a Markdown version.
|
|
31
|
+
- An entry's old URL is known only if it was published while IndexNow was on, so unpublishing or deleting an entry published earlier submits nothing.
|
|
32
|
+
- The news sitemap has no category include/exclude filter (Coywolf SEO's WordPress option); pick collections instead. Coywolf SEO's AI entity sections (a Labs feature) aren't ported.
|
package/docs/files.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# File Downloads
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
The Coywolf Files plugin for WordPress, on EmDash. Add a **File download** block to any entry and visitors get a download card: a colored file-type badge, the file name, a "PDF · 2.4 MB · Uploaded Mar 4, 2026" line, a Download button, and a Copy link button.
|
|
6
|
+
|
|
7
|
+
- **Block**: pick a file from the Media Library or from large uploads, then give it a title and description for that placement. Toggles show or hide the icon, description, meta line, Download, and Copy link. The card is server-rendered, scoped (`cw-file`), follows light/dark (or a fixed scheme), and ships one small script for Copy link (clipboard, checkmark, screen-reader announcement).
|
|
8
|
+
- **Download URLs**: `/download/<id>/<file name>`, served by the pack middleware. Files stream from R2 with `Content-Disposition: attachment`, the right `Content-Type`, `Content-Length`, `ETag`, conditional requests, and single byte ranges with `If-Range` (resumable downloads; a request for several ranges gets the whole file). Or set a public bucket / CDN URL and downloads redirect there.
|
|
9
|
+
- **Files page** (**Plugins → Files**): every file used in a File download block plus every large upload, with type, size, upload date, downloads, and the entries that use it. Search, In use / Unused filters, copy link, delete, and **Export CSV** (every file with its full download URL, size, download count, last download, and the entries that use it). Deleting a large upload removes the object from R2; the page lists the entries that still use it (their blocks then render nothing). Media Library files are deleted in the Media Library.
|
|
10
|
+
- **Large uploads**: files of any type and size (EmDash's own uploads stop at 50 MB and images, video, audio, and PDF) go straight from the browser to R2 in 8 MB+ parts, four at a time, with progress, retries, and Cancel. The Worker only signs URLs (AWS Signature V4 with Web Crypto, no AWS SDK).
|
|
11
|
+
|
|
12
|
+
## Feature switches
|
|
13
|
+
|
|
14
|
+
| Feature | Default | What it does |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `files` | off | The block, download URLs, and the Files page |
|
|
17
|
+
| `files.counts` | off | Count downloads (batched D1 writes after the response) |
|
|
18
|
+
| `files.largeUploads` | off | Large uploads to R2, up to 50 GB (needs the settings below) |
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
1. Turn on **File Downloads** under **Plugins → Coywolf Pack**. The middleware from the Redirects setup (`coywolfPack()`) serves the download URLs; nothing else to add.
|
|
23
|
+
2. Settings (**Files → Settings**): download URL base (default `download`), optional public bucket / CDN URL, card color scheme, accent color, and largest upload (default 5 GB).
|
|
24
|
+
3. Bindings: `DB` and `MEDIA`. To keep large uploads in their own bucket, bind it as `FILES` (or pass `files: { uploads: "MYBINDING" }`, and note the middleware looks for `FILES`).
|
|
25
|
+
|
|
26
|
+
## Large uploads
|
|
27
|
+
|
|
28
|
+
1. Create an R2 API token (**R2 → Manage API tokens**) with **Object Read & Write** on the bucket. Enter the account ID, access key ID, secret access key (stored encrypted), and bucket name under **Files → Settings → Large uploads**. Until all four are saved, the Files page shows a setup card instead of the uploader. The bucket must be the one bound as `MEDIA` (or `FILES`), since downloads stream through that binding.
|
|
29
|
+
2. Add a lifecycle rule to the bucket (**R2 → bucket → Settings → Object lifecycle rules**): **Abort incomplete multipart uploads after 1 day**. The pack also aborts uploads left unfinished for 24 hours in a daily task, but the rule catches anything the task can't reach (for example after the credentials change).
|
|
30
|
+
3. On the Files page, **Upload large file**.
|
|
31
|
+
|
|
32
|
+
The browser sends the file to the site in 6 MiB parts (`files/upload-part`), and the Worker uploads each to R2's multipart upload. Since EmDash 1.3.1, admin pages send `Content-Security-Policy: connect-src 'self'`, which blocks the browser from talking to R2 directly; the bucket needs no CORS policy. Each part must be exactly its expected size and belongs to the upload `files/upload-start` recorded, so a request can't write anywhere else. With 10,000 parts at most, a file can be up to just under 60 GB; the **largest upload** setting goes up to 50 GB.
|
|
33
|
+
|
|
34
|
+
Only administrators (`plugins:manage`) can upload, and uploads above the **largest upload** setting (default 5 GB, at most 50 GB) are refused. R2 storage and operations are billed to your Cloudflare account.
|
|
35
|
+
|
|
36
|
+
Files are stored as `files/<id>/<name>` with their metadata in plugin storage. Files moved from Coywolf Files for WordPress keep their 20-character WordPress id and object key (see [Migrating from WordPress](wordpress-import.md)). The pack calls only `<account>.r2.cloudflarestorage.com`.
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- Which entries use which file comes from an index updated on every save and delete. **Rebuild usage** rescans all content (needed once if blocks existed while the module was off). Entries moved to the trash stop counting as uses.
|
|
41
|
+
- Download counts are kept per isolate for a second and written together, so a burst of downloads costs one D1 batch. Counts made just before an isolate is evicted can be lost. A HEAD request, a 304, or a later range chunk isn't counted.
|
|
42
|
+
- Any ready Media Library item can be downloaded through `/download/<its id>/…`, whether or not a block uses it. That's the same exposure as EmDash's own `/_emdash/api/media/file/<key>` URLs: media files are public to anyone with the link.
|
|
43
|
+
- Lookups are cached per Worker isolate for 60 seconds, including "not found". A file requested just before its upload finished can keep answering 404 on that isolate for up to a minute.
|
|
44
|
+
- With a public bucket / CDN URL, downloads redirect to it, so the file name comes from the object key and the CDN decides the headers.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Private form uploads
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
The EmDash Forms plugin (`@emdash-cms/plugin-forms`) saves a file field's upload in the **media library**, where every file is public to anyone with its URL (`/_emdash/api/media/file/<key>`). With **Private form uploads** on, those files go to a private R2 bucket instead, and only admins can get them back.
|
|
6
|
+
|
|
7
|
+
- **Where files go**: an R2 bucket with no public access (no r2.dev URL, no custom domain), bound as `FORM_UPLOADS`. Nothing is written to the media library, so there's no public URL to cache.
|
|
8
|
+
- **Form uploads page** (**Plugins → Form uploads**): every private file, newest first, with its form and field, size, upload time, and the submission it came with (date and a short preview of the answers). **Download** and **Delete** per file.
|
|
9
|
+
- **Downloads**: an admin-only route (`plugins:manage`, EmDash's CSRF header required). The file is always sent as an attachment with a type that can't render (HTML, SVG, XML, scripts and unknown types go out as `application/octet-stream`), with `nosniff`, a sandbox CSP and `no-store`. The page saves the bytes as a file; it never opens them.
|
|
10
|
+
- **Cleanup**: deleting a submission in Forms (one, all of a form's, or by the form's retention setting) deletes its files. A daily task also removes files whose submission is gone or never saved, and, if **Delete files after (days)** is set, files older than that (the submission stays).
|
|
11
|
+
- **Which forms**: all forms (default) or only the ones checked on the Form uploads page.
|
|
12
|
+
- **Files from before**: a **Move to private bucket** button moves files that earlier submissions left in the media library, points each submission at its private copy, and deletes the media library copy.
|
|
13
|
+
|
|
14
|
+
Everything else about the form is still the Forms plugin's own: spam protection (honeypot, Turnstile), validation, the field's accepted types and size limit (and its 10 MB cap), notifications, webhooks, and the confirmation message. The private bucket takes the same file types the media library would (images, video, audio, PDF); anything else is refused, as before. File names are cleaned up (no paths, control or invisible characters, or characters Windows and macOS refuse; at most 120 characters) before they're stored or saved.
|
|
15
|
+
|
|
16
|
+
## Feature switches
|
|
17
|
+
|
|
18
|
+
| Feature | Default | What it does |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `formUploads` | off | Files from selected forms go to the private bucket |
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
1. Create the bucket and leave public access off: `npx wrangler r2 bucket create coywolf-form-uploads`.
|
|
25
|
+
2. Bind it in `wrangler.jsonc`:
|
|
26
|
+
|
|
27
|
+
```jsonc
|
|
28
|
+
"r2_buckets": [
|
|
29
|
+
{ "binding": "MEDIA", "bucket_name": "coywolf-media" },
|
|
30
|
+
{ "binding": "FORM_UPLOADS", "bucket_name": "coywolf-form-uploads" }
|
|
31
|
+
]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
3. Wrap the Forms plugin in `astro.config.mjs`:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { coywolfPlugin, privateFormUploads } from "@coywolf/emdash";
|
|
38
|
+
import { formsPlugin } from "@emdash-cms/plugin-forms";
|
|
39
|
+
|
|
40
|
+
plugins: [
|
|
41
|
+
coywolfPlugin({ /* … */ }),
|
|
42
|
+
privateFormUploads(formsPlugin({ defaultSpamProtection: "honeypot" })),
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Options (second argument): `bucket` (default `"FORM_UPLOADS"`), `mediaBucket` (default `"MEDIA"`, for moving earlier files), `database` (default `"DB"`).
|
|
47
|
+
4. Deploy, turn on **Private form uploads** under **Plugins → Coywolf Pack**, and check **Plugins → Form uploads**.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
`privateFormUploads()` keeps the Forms plugin's id, storage, settings and admin pages, and points its code entry at `@coywolf/emdash/forms`, which runs the Forms plugin's own routes and hooks with one change: the `ctx.media` they get. Its `upload()` writes to the private bucket (when the feature is on and the form is selected) and its `delete()` removes private files; every other call goes to the media library as before. The submission records the file as media id `coywolf-private:<id>`, and the file's details (form, field, name, size, submission) are kept in the Forms plugin's KV. The Form uploads page calls routes added to the Forms plugin at `/_emdash/api/plugins/emdash-forms/coywolf-private-uploads/*`.
|
|
52
|
+
|
|
53
|
+
## Notes
|
|
54
|
+
|
|
55
|
+
- With the feature on but no `FORM_UPLOADS` binding, a submission with a file fails ("File uploads are not configured") rather than storing the file publicly. If the switch can't be read, files stay private.
|
|
56
|
+
- Turning the feature off sends new files back to the media library and takes Form uploads out of the sidebar. Files already in the private bucket stay private; turn the feature back on to download or delete them.
|
|
57
|
+
- The Forms plugin's own admin doesn't show files; use the Form uploads page. Its CSV export and notification emails show the file name, and its webhook payload carries the `coywolf-private:<id>` media id.
|
|
58
|
+
- Downloads arrive in 4 MB parts (plugin responses are capped at 8 MB) that the page joins.
|
|
59
|
+
- The wrapper relies on the Forms plugin storing files through `ctx.media.upload()` and deleting them through `ctx.media.delete()` (true in 0.2.x). If a Forms update changes that, re-check before updating.
|
package/docs/headings.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Headings & TOC
|
|
2
|
+
|
|
3
|
+
[Coywolf Pack documentation](README.md) · [Getting started](../README.md#getting-started)
|
|
4
|
+
|
|
5
|
+
Ported from Coywolf SEO. Off until you turn it on under **Plugins → Coywolf Pack**:
|
|
6
|
+
|
|
7
|
+
| Feature | Default | What it does |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `headings` | off | Main switch |
|
|
10
|
+
| `headings.anchors` | off | Every H2–H6 gets an id like `jump-pricing`, plus an optional "copy link to section" button on hover and focus |
|
|
11
|
+
| `headings.toc` | off | **Table of Contents** block: title (shown or hidden, and its heading level, H2 by default, for always-open tables), heading levels (any of H2–H6), plain/bulleted/numbered (1, 1.1, 1.1.1), always open or collapsible (open or collapsed) |
|
|
12
|
+
|
|
13
|
+
Site defaults live on **Headings & TOC**: id prefix, copy link, scroll offset for sticky headers (px or rem), and TOC defaults (title, show title, levels, style, display, minimum headings, smooth scrolling). Breadcrumbs moved to their own module, [Breadcrumb Nav](breadcrumbs.md), in 0.6.0.
|
|
14
|
+
|
|
15
|
+
Anchors are written into the content when it's saved (an `anchor` field on each heading block), so they're unique within the entry and stay the same when a heading is reworded. A Table of Contents block stores the entry's heading list the same way, so the table always matches the anchors. After turning the features on, re-save an entry to stamp it; until then headings get ids from their text when the page renders.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
EmDash lets plugins add block types but not change how headings render, so the theme passes the pack's heading renderer to `PortableText` (one line, wherever content is rendered):
|
|
20
|
+
|
|
21
|
+
```astro
|
|
22
|
+
---
|
|
23
|
+
import { PortableText } from "emdash/ui";
|
|
24
|
+
import { portableTextComponents } from "@coywolf/emdash/astro";
|
|
25
|
+
---
|
|
26
|
+
<PortableText value={entry.data.content} components={portableTextComponents} />
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If you already pass components, merge them: `components={{ ...portableTextComponents, type: myTypes }}`. With the anchors and TOC features off, headings render exactly as before.
|
|
30
|
+
|
|
31
|
+
## Notes
|
|
32
|
+
|
|
33
|
+
- Output is server-rendered. Collapsing uses `<details>`, smooth scrolling is CSS (and is skipped for visitors who prefer reduced motion), and the only script is a small inline one for the copy-link button, sent only when that option is on.
|
|
34
|
+
- Markup follows Coywolf SEO's accessibility rules: the TOC is a labeled `<nav>` (its title is never a heading inside `<summary>`).
|
|
35
|
+
- The module declares the `content:write` capability (EmDash only registers a `content:beforeSave` hook for plugins that have it; the hook changes nothing but heading anchors and the TOC block's stored data) and `content:read`. Anchors are kept across edits by matching block keys (then heading text) against the stored entry. When an entry has unpublished draft revisions, anchors of headings added in an earlier draft are matched by text.
|
|
36
|
+
- Styles use `cw-` classes and the theme's colors, in light and dark mode.
|
|
37
|
+
- With the features off, markup is unchanged, but the components' small global stylesheet (`cw-` classes only) is still bundled on pages that import them.
|